Files
mirror/SECURITY.md
T
ducoterra d0077e0db4
Build and Push Containers / test (push) Successful in 45s
Build and Push Containers / build-and-push-app (push) Successful in 13s
phase: 09_wall_remote_page
**Phase 09 — final verification pass (all 3 tasks already in `complete/`; phase work was in the working tree, verified, nothing needed fixing)**

**Gates** — `uv run ruff check . && uv run ruff format --check .` → clean; `uv run pyright` → 0 errors; `uv run pytest` → **360 passed, 5 skipped** (only the realdisplay rung), coverage 96% total / 95% app.py; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed, 1 skipped (documented host limitation: no audio source on the real X11 path); `uv run pytest tests/e2e/test_remote_control.py` → 8/8 passed; `node --check` on every `static/*.js` → all pass.

**Completion criteria** — all met: (1) "Show on wall" stages on the display, asserted on display DOM + `/api/kiosk` ✓; (2) Back/Fullscreen + untouched second controller converge (TestConvergence) ✓; (3) `/api/streams` reports `viewers: 0` / `watching: 0` with only `/remote` open (TestACostsNothing) ✓; (4) followers count displays only — 0/1/1 with a second controller (TestFollowers) ✓; (5) controller works with no display: `followers: 0`, state stored (protocol test + e2e) ✓; (6) e2e + full `-m e2e` + realdisplay all green ✓; (7) README + SECURITY.md updated, Silverblue default now `/kiosk` (installer, conf, gnome script, checker) ✓; (8) suite 100% green, coverage >90% on new code ✓; (9) PLAN.md untouched; AGENTS.md changed only for a stale statement ✓.

**Documentation** — README.md reviewed in full against code (page table, quickstart, env-var table vs `config.py`/`debug.py`, presets, keys, deployment, kiosk section): no inconsistencies found. AGENTS.md: fixed the one stale statement introduced by this phase ("Four pages" → "Five pages"); no other staleness, nothing added.

**Decisions** — no repo changes were required; the one realdisplay skip is a pre-documented environment fact, not a failure.

**Next pending phase** — none; `todo/` held only phase 09.
2026-11-03 23:50:43 -05:00

11 KiB
Raw Blame History

mirror — security model

mirror has no authentication. That is a design decision, not an oversight. It changes the question from "who is allowed in?" to "where is this allowed to run?". This file answers the second question honestly.

1. Trust model

Trusted Anyone inside the address space the server answers — with no allow list configured that is anyone who can open a TCP connection, and in the fleet deployment it is the client LAN (10.2.0.0/22) and nothing else
Untrusted Anyone outside that space — including anything on the LAN that was not named in it (guest Wi-Fi, IoT VLAN, a cafe), and everything on the internet
Not a threat model Malicious JavaScript served from this origin (there is only one origin and you run the server), supply-chain of a machine you already control

The product promise is "open a URL, share your screen". Every gate we add attacks that promise, so the boundary is drawn at the network instead of the application.

Run it only on a network you control. Do not port-forward it. Do not put it behind a reverse proxy that is reachable from the internet. It has no rate limiting, no accounts, and no audit trail.

2. What an unauthenticated visitor can do

  • Mirror any surface their own browser's picker offers them — to everyone else watching.
  • Watch any mirror anyone else is sharing.
  • Learn which device names and window titles are being shared (window titles can be sensitive: mail, tickets, filenames).
  • Consume a sharer's upstream bandwidth (per leg, bounded by MIRROR_MAX_BITRATE; the sharer's quality preset scales that bound only down — 1.0× / 0.6× / 0.35× — and can never raise it above the server's ceiling).
  • Rearrange anybody's wall display: choose which mirror is on stage and whether it is fullscreen, on every /kiosk client, immediately — open /remote on a phone, or send one arrange frame on /ws. This is the same trust decision as "watch anything anyone shares": it is visible, it is presentation-only (no sound, see below), and MIRROR_ALLOW_CIDRS is what bounds it.
  • What a visitor cannot do: make a display audible. The layout carries no sound field, and every audible state traces to a gesture on the device that plays it (the §4 “Audio” gesture rule) — a remote hand can stage and scale, but a stranger's display never sounds because they asked.

3. What they cannot do (enforced and tested)

Guarantee Enforcement Test
No unsolicited media negotiation. A sharer cannot push an offer to a viewer who did not subscribe; a viewer cannot reach a sharer it never subscribed to Registry.signal() allows traffic only along an existing subscription, in either direction test_sharer_may_not_blindly_push_to_a_stranger, test_viewer_may_not_reach_a_sharer_it_never_subscribed_to, test_signalling_a_peer_that_never_subscribed_is_refused
No peer-to-peer chatter between watchers. Viewer↔viewer signaling is refused outright peer_to_peer refusal test_peers_may_not_talk_to_each_other_directly
No identity spoofing of the roster. A viewer cannot publish a mirror; a peer cannot claim another's stream role checks (not_sharer, not_viewer), stream ids are server-assigned test_one_peer_cannot_impersonate_another, test_a_viewer_cannot_publish
No oversized signaling. Frames above 64 KiB are dropped before parsing MAX_MESSAGE_BYTES test_an_oversized_frame_is_refused
No protocol fuzzing into a crash. Bad JSON, non-object frames, unknown types and unknown roles produce an error frame; only fatal ones close the socket Hub.serve/_dispatch TestSignalingSocket
No remote audio, no remote mute override. The layout carries no sound field, and every audible state traces to a gesture on the device that plays it the layout model has no audio field, plus the kiosk.js/remote.js source guards test_kiosk_never_publishes_an_audio_field, test_a_controller_is_media_free, test_m_flips_the_staged_videos_muted_flag, test_only_the_staged_mirror_can_be_unmuted
No leaked peer connections. Every teardown path notifies every viewer Registry._drop_stream, leave() test_sharer_disconnect_removes_the_mirror, test_unpublish_clears_the_tile_for_every_viewer
Nothing leaves the network. No CDN, no remote font, no analytics, no STUN by default A10/A4 + a build-failing test test_nothing_is_loaded_from_a_cdn, test_config_does_not_leak_server_secrets
The server cannot see your screen. Media is SRTP between peers; the server relays JSON only A3 Architectural: no media code exists in Python

4. Platform realities you should tell users about

Screen capture requires a secure context

getDisplayMedia() exists only in a secure context. http://localhost qualifies; http://192.168.x.x does not — the browser hides the API entirely. The /mirror page detects this and prints the three ways forward:

  1. Share from the machine running the server, via http://localhost:8055/mirror.
  2. Put TLS in front of it. This is no longer a plan: the fleet deployment terminates HTTPS in nginx on the service VM (https://mirror.reeseapps.com), so every device there can share. mirror serve --https for standalone use is still open (A18).
  3. Per-device and explicit: chrome://flags/#unsafely-treat-insecure-origin-as-secure with the exact origin, then reload.

Option 3 is a deliberate, per-device, user-granted exception. Do not automate it on someone else's machine without telling them what it does: it marks that origin as trusted for powerful APIs, permanently, on that profile.

Access control is an address list, not an account

Because there is nothing to log in with, the source address decides everything. That makes the list strong and also makes it the only thing standing between a stranger and every screen in the building, so treat it as the security boundary it is:

Layer Mechanism Bypass it and you have
Network routing / firewall, allow/deny in nginx nothing — this is the wall
App MIRROR_ALLOW_CIDRS in the container a process already running on the server host

The app layer exists because a container port can be reached without going through the proxy (127.0.0.1:8080 on the service VM). It judges the X-Forwarded-For value only from an address in MIRROR_TRUSTED_PROXIES, and the proxy must overwrite that header (proxy_set_header X-Forwarded-For $remote_addr) rather than append, so a browser cannot write its own pass. What the app layer cannot do is resist someone who already has code on the server: at that height they can forge the header, so it is a second lock on the outside door and not a host firewall. Say that plainly rather than implying defence in depth where there is one wall and one belt.

Two things the address list does not do, both inherent to WebRTC:

  • Once two browsers are in the room, their media flows directly between them and never consults the server again. Admission to the roster is admission to the stream.
  • It cannot tell you who is watching — only where from. Device labels are self-reported and cosmetic; never treat one as an identity.

Wayland asks every time; X11 does not

On X11/Xorg, Chromium uses its native capturer: the picker appears and that is all.

On Wayland (GNOME/KDE), Chromium routes capture through xdg-desktop-portal. The compositor shows its own permission dialog and, depending on the portal, may ask for each share rather than remembering. Expect a native prompt — that is the compositor protecting the user, and mirror must not try to work around it.

/watch is unaffected: receiving never needs permission.

Audio

Audio is user-initiated by design: it never starts on its own, and a reconnect never revives it.

What audio exists is the browser's decision, not mirror's. The picker offers it (a "share audio" tickbox in Chrome, tab audio for tabs), and mirror accepts the track when the source has one — falling back to a silent capture, rather than failing the share, when it does not. A source that loses its audio mid-share is announced: the sharer's panel flips and every viewer's tile loses its button, instead of keeping a dead control.

On the wall, only the staged mirror can be audible. Every mirror starts muted, and the one deliberate gesture that unmutes — the tile's audio button or the m key — is always a click or a keypress. Unmuting one mirror mutes the others, and leaving the stage re-mutes the one you left.

What system audio a browser can capture is a platform fact, stated plainly:

  • Chrome on Linux captures tab audio reliably, and whole-screen/ system audio only where the platform offers it — PipeWire/PulseAudio's loopback, plus the picker's "share audio" tickbox.
  • Firefox's desktop capture does not offer system audio at all on Linux. A wall of Firefox tiles is silent by the browser's rule, not mirror's.

mirror neither works around that nor fakes it: when a source has no audio, the sharer's panel says silent (the browser honoured the request; the source has no track) or not offered (the capture only succeeded after the audio constraint was dropped), and the wall shows no button at all.

5. Operational guidance

  • Name the networks, do not just bind. --allow-cidr 10.2.0.0/22 (or MIRROR_ALLOW_CIDRS) is the setting that actually restricts who, whereas MIRROR_HOST only restricts which interface. MIRROR_HOST=0.0.0.0 with an allow list is the deployment shape; MIRROR_HOST=127.0.0.1 is how you keep a dev instance to yourself.
  • A refusal should explain itself. A refused client gets a 403 naming the networks that are allowed and the address that was judged — because "not allowed" in front of a proxy is undiagnosable otherwise. Keep that behaviour if you touch the gate.
  • Firewall the port. Restrict 8055 to the LAN range you actually want: firewall-cmd --add-rich-rule='rule family=ipv4 source address=192.168.1.0/24 port port=8055 protocol=tcp accept'
  • Cap the blast radius. MIRROR_MAX_MIRRORS limits concurrent mirrors; MIRROR_MAX_BITRATE bounds per-viewer upload — the sharer's quality preset (1.0× / 0.6× / 0.35× of that bound) can only ever lower it, never raise it above the server's ceiling.
  • Egress stays closed. The container image makes no outbound connections; do not add STUN unless you are deliberately bridging VLANs.
  • Assume window titles are public. Say so before you mirror a desktop full of private windows.
  • Rotating peer ids are not anonymity. Peer ids are random per socket, but a sharer's IP is visible to every watcher on the network by design (that is how peer-to-peer works).

6. Reporting

Treat anything that breaks a guarantee in §3 as a security bug, even if it "only" lets one watcher see a mirror they did not subscribe to. Add a failing test first — the guarantees above are tests, not prose.