PLAN.md locks the architectural decisions (WebRTC mesh with the sharer as sole offerer, JSON-only signaling, in-memory roster with no database, no auth, no CDN, Linux as the reference platform) and records the roster rules, the verification ladder, and the constraints that are accepted rather than hidden: the secure-context requirement, mesh upload scaling with watcher count, and the Wayland portal's per-share prompt. Phases are deliberately not written yet -- the prototype and its tests came first, so .agents/phases/todo/ holds only a README pointing at the candidate phase list in PLAN.md section 7. SECURITY.md answers the question a no-auth tool has to answer: not "who is allowed in" but "where is this allowed to run". It separates what an unauthenticated LAN visitor can do from the guarantees that are enforced and named to a test, and tells users the two things they will actually hit -- that http://<lan-ip> hides getDisplayMedia, and that on Wayland the compositor asks every time and mirror must not work around it. LLM-Generated: true
6.0 KiB
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 who can open a TCP connection to the server's port, and every host on the same L2/L3 network as them |
| Untrusted | Anyone who can reach that port and is not in the above set — including anything on the LAN that should not be (guest Wi-Fi, IoT VLAN, a cafe) |
| 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 (bounded by
MIRROR_MAX_BITRATE, per leg).
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:
- Share from the machine running the server, via
http://localhost:8055/mirror. - Serve over HTTPS (planned:
mirror serve --httpswith a self-signed cert). - Per-device and explicit:
chrome://flags/#unsafely-treat-insecure-origin-as-securewith 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.
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
Sharing tab/window audio is opt-in in the browser's own picker. mirror falls back to silent capture when the source has no audio rather than failing the share, and viewers start muted (autoplay policy) and must tap to unmute — a deliberate, user-initiated action.
5. Operational guidance
- Bind narrowly.
MIRROR_HOST=0.0.0.0is the default because the point is other devices. If only this machine should reach it,MIRROR_HOST=127.0.0.1. - 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_MIRRORSlimits concurrent mirrors;MIRROR_MAX_BITRATEbounds per-viewer upload. - 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.