feat: decide who may connect at all, by source address

mirror has no accounts, so on a real network the address a connection arrives
from is not a nicety — it is the entire access control. Before this change the
answer was "everyone who can open a TCP connection", which is fine for a laptop
demo and alarming for a box someone leaves running.

`mirror serve --allow-cidr 10.2.0.0/22` (or MIRROR_ALLOW_CIDRS) now restricts
pages, the JSON API and the signaling socket to the networks named, and it is
strict where it needs to be: a malformed CIDR exits 2 with one line instead of
starting a server whose list silently failed to parse, because the two
outcomes of that typo are "nobody can connect" and "everybody can connect".
Empty remains the only permissive state, on purpose.

The rules that matter live in src/mirror/access.py and are the ones that get
either outcome:

  * X-Forwarded-For is believed only from MIRROR_TRUSTED_PROXIES (loopback by
    default), and only its left-most value. An untrusted peer that sends the
    header gets judged on its own address — there is a test for exactly that,
    because it is the shape of a bypass.
  * The socket is closed before it accepts, so a refused address never creates
    a roster entry to clean up behind it.
  * An address that cannot be parsed fails closed, and a v6 address is never
    "inside" a v4 network (comparing the two raises TypeError, which would be
    a 500 on a dual-stack host).
  * /healthz is exempt: the container health probe comes from loopback, which
    a production list otherwise excludes.

The refused client is told why, and which address it was judged as — "403" in
front of a proxy is undiagnosable otherwise. Browsers get a page, fetches get
JSON, and the page is self-contained because a refused address cannot load
/static/app.css any more than it can load a page. net.js keys its own notice on
the /api/config 403 and stops: without that the pill reads "offline,
reconnecting in 32s" forever for a decision that will never change.

The static files drop their only inline script and style attribute (the landing
page's counter is /static/home.js, its fine print is .fineprint) so the
deployment can send `script-src 'self'; style-src 'self'` with no
'unsafe-inline'. That policy was verified through a real nginx edge carrying the
exact header set — capture through SRTP to decoded, painted frames at
1280x720, eight distinct, zero securitypolicyviolation events — which also
caught the edge sending `Cache-Control: no-store, no-store` and needed
proxy_hide_header, since nginx appends rather than replaces.

24 tests added; 176 pass, 2 skipped, pyright and ruff clean.

LLM-Generated: true
This commit is contained in:
2026-11-01 17:47:31 -05:00
parent 24cb10ee5c
commit 7988da2b93
16 changed files with 771 additions and 55 deletions
+107
View File
@@ -9,8 +9,22 @@ import subprocess
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from mirror.access import parse_cidrs
from mirror.app import create_app
from mirror.config import Settings
STATIC = Path(__file__).resolve().parent.parent / "src/mirror/static"
def gated(*allow, client_address="203.0.113.9", **kwargs):
"""A client speaking as ``client_address`` to a server restricted to ``allow``."""
settings = Settings(allow_cidrs=parse_cidrs(",".join(allow)), **kwargs)
return TestClient(create_app(settings), client=(client_address, 44444))
EXTERNAL_REF = re.compile(r"""(?:src|href)\s*=\s*["']https?://""", re.IGNORECASE)
@@ -166,6 +180,99 @@ class TestStylesheet:
assert offenders == []
class TestAddressGate:
"""mirror has no accounts; the address is the access control."""
ALLOW = "10.2.0.0/22"
def test_unset_allows_everyone_which_is_the_development_default(self):
with gated(client_address="203.0.113.9") as client:
assert client.get("/watch").status_code == 200
@pytest.mark.parametrize("path", ["/", "/mirror", "/watch", "/api/streams", "/static/app.css"])
def test_a_refused_address_gets_no_page_no_roster_no_assets(self, path):
with gated(self.ALLOW) as client:
assert client.get(path).status_code == 403
def test_the_probe_stays_answerable_from_anywhere(self):
"""The container health check comes from loopback, which the list excludes."""
with gated(self.ALLOW) as client:
response = client.get("/healthz")
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_a_browser_gets_an_explanation_rather_than_json(self):
with gated(self.ALLOW) as client:
response = client.get("/mirror", headers={"accept": "text/html"})
assert response.status_code == 403
assert "text/html" in response.headers["content-type"]
body = response.text
assert self.ALLOW in body
assert "203.0.113.9" in body # which address was judged, or this is undiagnosable
def test_the_refusal_page_cannot_depend_on_assets_the_client_cannot_fetch(self):
with gated(self.ALLOW) as client:
body = client.get("/", headers={"accept": "text/html"}).text
assert not EXTERNAL_REF.search(body)
assert "/static/" not in body
def test_a_fetch_gets_the_machine_readable_refusal(self):
with gated(self.ALLOW) as client:
response = client.get("/api/config")
assert response.status_code == 403
assert response.json() == {
"error": "forbidden",
"message": "This mirror only accepts connections from 10.2.0.0/22.",
"you": "203.0.113.9",
}
def test_a_refusal_is_never_cacheable(self):
with gated(self.ALLOW) as client:
for headers in ({"accept": "text/html"}, {}):
response = client.get("/api/config", headers=headers)
assert response.headers["cache-control"] == "no-store"
def test_the_socket_closes_before_it_accepts(self):
with (
gated(self.ALLOW) as client,
pytest.raises(WebSocketDisconnect),
client.websocket_connect("/ws") as socket,
):
socket.send_json({"type": "hello", "role": "viewer"})
def test_the_real_address_is_in_and_connects(self):
with gated(self.ALLOW, client_address="10.2.1.10") as client:
assert client.get("/watch").status_code == 200
with client.websocket_connect("/ws") as socket:
assert handshake(socket, "viewer")
def test_a_trusted_local_proxy_carries_the_client_through(self):
"""The fleet shape: nginx on the same host, X-Forwarded-For set by nginx."""
with gated(self.ALLOW, client_address="127.0.0.1") as client:
assert client.get("/watch", headers={"x-forwarded-for": "10.2.1.5"}).status_code == 200
with client.websocket_connect("/ws", headers={"x-forwarded-for": "10.2.1.5"}) as socket:
assert handshake(socket, "viewer")
def test_a_trusted_proxy_carrying_a_foreign_client_is_still_refused(self):
with gated(self.ALLOW, client_address="127.0.0.1") as client:
assert client.get("/watch", headers={"x-forwarded-for": "8.8.8.8"}).status_code == 403
with (
pytest.raises(WebSocketDisconnect),
client.websocket_connect("/ws", headers={"x-forwarded-for": "8.8.8.8"}),
):
pass
def test_an_untrusted_peer_cannot_write_its_own_pass(self):
with gated(self.ALLOW, client_address="203.0.113.9") as client:
response = client.get("/watch", headers={"x-forwarded-for": "10.2.1.5"})
assert response.status_code == 403
assert response.json()["you"] == "203.0.113.9"
def test_both_address_families_can_be_admitted_side_by_side(self):
with gated(f"{self.ALLOW}, fd00::/64", client_address="fd00::1234") as client:
assert client.get("/watch").status_code == 200
class TestJsonApi:
def test_config_advertises_the_media_defaults(self, client):
body = client.get("/api/config").json()