**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.
181 lines
11 KiB
Markdown
181 lines
11 KiB
Markdown
# 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.
|