Files
mirror/SECURITY.md
T
ducoterra 736da1c1ef
Build and Push Containers / test (push) Successful in 39s
Build and Push Containers / build-and-push-app (push) Successful in 12s
phase: 07_kiosk_layout_state
Every AGENTS.md rule claim checks out against the code — no stale statements. All verification is complete.

**Phase 07 — final verification pass report**

- **Verified, not re-implemented:** all 3 tasks already in `complete/` — `Arrange`/`layout` frames + `hello.kiosk` (protocol), `Layout`/`set_layout`/`layout_dto` policy (registry), `arrange` dispatch + `GET /api/kiosk` (app).
- **Live check:** idle `/api/kiosk` → `{"staged": null, "fullscreen": false, "followers": 0}`; drove `arrange` over a real WebSocket (websocat absent → python `websockets` client): state reflected in `/api/kiosk`, kiosk join bumped `followers` to 1 and pushed to other viewers, sharer teardown reset `staged` to `null` in exactly one `layout` frame — no stale id.
- **Gates (all 4):** `uv run ruff check . && uv run ruff format --check .` → clean; `uv run pyright` → 0 errors; `uv run pytest` → **319 passed, 5 skipped** (realdisplay, by design); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → **4 passed, 1 skipped** (documented host has no audio source).
- **Criteria 1/4/5:** targeted suite 154 passed incl. all new layout cases; stale-fallback unit tests cover unpublish, disconnect, and stream-replacement; `git diff` of `src/mirror/static/` and `tests/e2e/` is empty, e2e green within the full run.
- **Criterion 6:** full suite 100% green; coverage 95% total — new/modified code: protocol 100%, registry 99%, app 94% (all >90%).
- **Criterion 7:** `PLAN.md` untouched.
- **Documentation:** README.md reviewed in full and cross-checked against code (routes, CLI flags, config table incl. 25 Mbit/s default, quality-preset table, watch/share key tables, docs references, the phase-07 shared-state paragraph) — consistent, no fixes needed. AGENTS.md — verified, no stale statements, no changes.
- **Note:** `PLAN.md` §6 says the default `MIRROR_MAX_BITRATE` is "12 Mbit/s" but code/README say 25 — pre-existing, in a LOCKED file, left as-is (README is correct).
- **Next pending phase:** `08_kiosk_page`.
2026-11-03 09:53:38 -05:00

175 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).
* Point the shared wall at any live mirror and toggle its fullscreen state
(the `arrange` frame on `/ws`), which every kiosk display follows. The
state has no audio field: a remote hand can stage and scale, but it can
never make a display *sound* — that still needs a gesture on the device
playing it (see §4 “Audio”).
## 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 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.