Skip to content

video: support five streams and improve per-entry access controls - #43

Open
tridge wants to merge 10 commits into
ArduPilot:mainfrom
tridge:pr-video-5-slots
Open

video: support five streams and improve per-entry access controls#43
tridge wants to merge 10 commits into
ArduPilot:mainfrom
tridge:pr-video-5-slots

Conversation

@tridge

@tridge tridge commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

  • expand per-entry video capacity from three streams to five while preserving the append-only keys.tdb layout
  • add a per-slot MAVLink-session publishing fallback for cameras that cannot present the configured publish password
  • expose the new slot policy in the web UI and improve password-field help rendering
  • ignore unsupported RTMP data streams instead of rejecting otherwise usable publishers
  • add per-entry Private, Login Required, and Public read-only log access with delete operations still restricted to owners/admins

Compatibility

Existing key records remain valid: slots four and five use fields appended after the established record layout, and the new access flags default existing entries to their previous private behavior.

Testing

  • make -j2
  • pytest -q tests/webadmin — 287 passed
  • pytest -q tests/test_video_schema.py tests/test_video_ports.py tests/test_video_rtsp.py — 84 passed

tridge added 8 commits August 18, 2026 13:28
An aircraft can carry more than three cameras, and one port carries one
stream, so the cap was the limit on how many a single entry could
proxy.

The three fields that hold per-slot state -- video_ports, video_flags
and video_rtmp_path -- all sit in the middle of the record, so none of
them could simply grow: every field after them would shift, every record
already on disk would be misparsed, and an older binary would read
garbage. The append-only contract at the top of keydb.h exists to make
that unnecessary, so slots 3 and 4 are carried in new fields appended
after reserved[], and accessors join the two halves. A record written
before they existed zero-extends into them, which reads as two unused
slots, so nothing needs converting and the live database keeps working.

video_flags could not be widened for the same reason, and it was already
full: three slot bytes plus the entry-wide byte is exactly 32 bits. A
fourth slot byte at shift 24 would have landed on the entry options --
which is what happened first time round, and is why there is now a test
that sets slots 3 and 4 to 0xFF and checks the audio flag survives.

The record grows 344 -> 456 bytes. Also fixes video_port_count()
tripping over a short list, which callers that build a KeyEntry by hand
were relying on not happening.

The README's video section had been pasted in three times: the edit that
added it replaced on "## Building", which matches three headings. Only
one copy remains.
Bit 3 of each slot's option byte, which was free, so no record growth
and no migration -- an existing record reads it clear, which is the
current behaviour.

It marks a slot whose publisher may be admitted by the entry's MAVLink
session even though a publish password is set. Some publishers cannot
present one: a camera speaking RTMP from its own firmware has nowhere
to put a credential unless its stream-key field tolerates a query, and
plain MPEG-TS over UDP never does. Without this an entry faced an
all-or-nothing choice between a password and those streams.
admit() gains the slot's session_ok bit. With it set and no credential
offered, admission falls through to the MAVLink-session path instead of
refusing; without it, nothing changes.

The fallback deliberately does not apply to a credential that was
offered and is wrong. Downgrading that to address matching would turn a
clear rejection into a silent weakening, so a typo cannot succeed on
the strength of the source address.

Five tests, of which two guard the behaviour being preserved: an
unflagged slot still refuses a session-only publisher, and a wrong
password is still refused on a flagged one. Verified RED by forcing the
bit false -- the three that assert the new path fail, the two guards
still pass.
Rendered in the existing per-slot options row and documented in the
template beside it, as the other three are. The forms.py tooltip check
exempts the per-slot booleans, so the guard that they really are
documented where they are rendered is extended to cover it.
Pasting into it with the tooltip showing wedges Chrome's renderer: the
tab stops responding to input entirely, and it does not recover. The
text it carried moves into the blurb above the form, which already
introduced the field, so nothing is lost.

The forms.py "every option is documented" guard exempts the field and
records why, so it cannot be quietly reinstated.
A tooltip over a password field wedges Chrome's renderer: the tab stops
accepting input and does not recover. Reproduced on the login form and
removed there; this covers the rest, which share the markup and so
presumably share the fault.

The help itself is worth keeping -- the publish-password text explains
that it replaces the address check, and the new-passphrase one that
blank means unchanged -- so the macro renders a password field's
description in flow as .field-hint rather than dropping it. The dotted
underline that advertises a tooltip goes with it, and aria-describedby
is now emitted only when there is something to point at.

Two guards: no password input on any page carries a .tip, and the text
still reaches the page. Both verified RED.
@tridge

tridge commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Deprecated — see below for the updated review after the harden-publish-session-fallback fix.

Previous review (2026-08-25, at head 1174f36)

Automated review note — AI-generated (Claude), validated against the live diff. Please sanity-check before acting.
Full report (with the other DevCallEU PRs): https://uav.tridgell.net/DevCallReviews/DevCallEU/devcall_pr_reviews.html

Reviewed at head 1174f36b03 — verdict: COMMENT. Careful, security-conscious PR; the 5-slot ABI split, per-slot isolation and log gate are all sound. My own read first cleared it APPROVE (the admit() decision itself is correct); an independent cold security pass flagged the credential-presence handling, which I traced against source. Two of the cold pass's claims I refuted on inspection (see below), so the confirmed set is narrower than a raw cold read would suggest.

ISSUE — a supplied-but-wrong publish password can be read as "absent" and take the address fallback. admit() is correct — it returns BAD_PASSWORD before any session_ok consideration when a credential is supplied — but "credential absent" is computed upstream as *password != '\0' on a bare string with no explicit present-bit, and four paths collapse a supplied-wrong value to empty on a session_ok+non-bidi slot:

  • RTSP reads the credential with a single MSG_PEEK that doesn't wait for a complete request line (video.cpp:639-660) — withhold ?pw=wrong from the first segment and the rest is spliced to ffmpeg with no re-auth;
  • the publish path matches only a literal ?pw= (video.cpp:647), so /cam?mode=x&pw=wrong reads as no-credential (the robust HttpRequest::query() is used only on the viewer path);
  • http_url_decode permits %00 (httpreq.cpp:142), so ?pw=%00wrong is NUL-first and tests as absent;
  • RTMP split_credential lets the last parameter win (videortmp.cpp:296), so FPV?pw=wrong&pw= arrives empty.

Not an access-control bypass — every vector needs the attacker already at the authorized session address on a flag-enabled, non-bidi slot, where credential-free publish is intended — but it means the README's "a typo cannot quietly succeed on the strength of the address" isn't honoured. A uniform fix: represent presence with an explicit flag (independent of the decoded value), require a complete bounded request line before deciding, reject embedded NUL, and use the robust query parser on the publish path too.

ISSUE — signed-session fallback can never succeed (fails closed). ConnEntry.authenticated is read by the bidi gate (videoauth.cpp:70,99) but never written non-zero — the conn_write sites (supportproxy.cpp:1103,1132) zero-init and never copy is_authenticated() — so a KEY_FLAG_BIDI_SIGN slot always returns UNAUTH on the session path. Safe, but it contradicts the documented intent (conntdb.h:94-96): session_ok on a signed entry won't work as described.

NOTE — stale comments: keydb_lib.py still says the record is 344 bytes and set_video_ports() says "up to 3"; the implementation is correct at 456 bytes / 5 slots.

Refuted from the cold pass and not issues: the session-name collision check is not still bounded to 3 (every video-slot loop uses MAX_VIDEO_PORTS=5; the only literal 3 is KEY_VIDEO_PORTS_INLINE, the on-disk struct split, correct by design); and the RTMP unsupported-stream change only alters the ffmpeg -map (nothing leaks our side).

Verified clean: append-only ABI (old 344-byte records zero-extend so the new flag defaults off on migration), per-slot/per-entry isolation, port allocation stops correctly at 65535, and the log gate re-validates the viewer each request with traversal closed. Please confirm CI green before merge given this touches an auth path.

Preserve the distinction between absent and supplied publish credentials across RTSP and RTMP parsing, and export signed MAVLink authentication state for bidi session fallback. Update stale five-slot schema documentation and add regression coverage.
@tridge

tridge commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Automated review note — AI-generated (Claude), validated against the live diff. Please sanity-check before acting.
Full report (with the other DevCallEU PRs): https://uav.tridgell.net/DevCallReviews/DevCallEU/devcall_pr_reviews.html

Re-reviewed at head a9ab58d571 after your video: harden publish session fallback commit — my earlier comment above is superseded. Verdict: COMMENT. The redesign is the right one, and it clears almost everything — but a cold adversarial pass reproduced one residual vector with a live exploit, so I don't think the invariant fully holds yet.

Resolved (verified):

  • 1(c) presence is now explicit and length-aware — admit() takes const std::string *password + a separate credential_capable for the log string, and password != nullptr ? (matches?OK:BAD_PASSWORD) runs before any session fallback (videoauth.cpp:108-146); empty/NUL-first stay non-null and fail matching.
  • 1(a) RTSP complete-line guard, fail-closed, 2 s deadline from connect (videoview.cpp:199-220, video.cpp:639-663).
  • 1(b) real query parser http_query_value(...,"pw",...) replaces find("?pw=") (video.cpp:659, httpreq.cpp:140-166).
  • 1(d) RTMP within-query duplicate — first pw wins, separate password_present_ (videortmp.cpp:278-300).
  • Finding 2 authenticated now set on the user-side row the bidi gate reads (supportproxy.cpp:1100).
  • Finding 3 stale keydb_lib.py comments fixed.

Residual — the hardening isn't quite complete:

  • BUG (reproduced live) — a duplicate RTMP app property erases a supplied wrong password. amf_object_strings is last-wins on duplicate keys (videortmp.cpp:739, if (key == k1) v1 = sv; no break), so a connect object with two app fields —
    app = PhoenixFPV?pw=wrong
    app = PhoenixFPV
    tcUrl = rtmp://127.0.0.1/PhoenixFPV
    
    leaves app_ = "PhoenixFPV"; split_credential then finds no query, password_present_ stays false (videortmp.cpp:740-747), and on a session_ok slot with a matching session address it logged RTMP publishing, not wrong publish password. The "a duplicate cannot erase it" comment guards duplicate pw= within one query, not a duplicate app property upstream. Fix: record presence from the raw connect object before collapsing duplicate keys (or reject a duplicate app).
  • ISSUE — RTSP admission examines only the first request (video.cpp:624-710): the line guard fixed fragmentation, but an uncredentialed OPTIONS can take the fallback before a later ANNOUNCE ...?pw=wrong is seen. If "supplied" is meant session-wide, that's another downgrade.
  • NOTEFCPublish credentials are ignored (videortmp.cpp:811-834 reads the name but never runs split_credential), and pw key matching is case-sensitive (?PW= reads as absent — defensible as the opt-out, but part of the pattern).

As before, none of these is an arbitrary-attacker bypass — each needs the attacker already at the authorized MAVLink-session address on a session_ok+non-bidi slot — hence COMMENT not a block. But since the commit's whole point is that a wrong password can't quietly succeed, the RTMP app-duplicate case (standard field, standard pw spelling) is worth closing. Verified clean on the other side: the complete-line handling is DoS-bounded and the admit() ordering has no init/regression.

Reject duplicate RTMP connect properties, preserve the first credential across RTMP setup commands, and continue validating credentials on every RTSP request. Bound RTSP framing, reject ambiguous content lengths, and cover the reported downgrade cases with integration tests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant