diff --git a/docs/config.md b/docs/config.md index d01bf1ad..f6c439db 100644 --- a/docs/config.md +++ b/docs/config.md @@ -998,6 +998,35 @@ Approve/Decline cards in the web UI. | `gateway.ssl.cert` | path | - | SSL certificate path | | `gateway.ssl.key` | path | - | SSL private key path | +### Favicon + +Drop `favicon.svg`, `favicon.png` or `favicon.ico` into `workspace/config/` and +the gateway serves it at `/favicon.ico`. There is no setting: the file is tracked +config like anything else under `config/`, so a config repo carries it to every +instance that syncs, and a fleet can be told apart by its browser tabs. Nothing +is copied into the web bundle — the file is read per request, so one that arrives +by sync appears without a restart, and removing it goes back to the browser +default. + +Use one. If several are present the first of `.svg`, `.png`, `.ico` wins. + +A symlink is followed only while it stays inside `workspace/config/`. Git tracks +symlinks, and `/favicon.ico` is served without authentication because a browser +asks for it before anyone logs in — so a `config/favicon.png` pointing at +`/etc/shadow` would otherwise be readable by anyone who can reach the port. One +pointing at another file under `config/` is fine. + +An SVG goes out under `Content-Security-Policy: default-src 'none'` — with +`img-src data:` and `style-src 'unsafe-inline'` so ordinary icons still render — +and every format gets `X-Content-Type-Options: nosniff`. Navigating straight to +`/favicon.ico` makes an SVG a same-origin *document* rather than an image, and a +`" + '' + ) + + def test_csp_forbids_script_and_network(self, tmp_path, monkeypatch): + client, ws = _client(tmp_path, monkeypatch) + (ws / "config" / "favicon.svg").write_text(self._SVG_WITH_SCRIPT) + r = client.get("/favicon.ico") + assert r.status_code == 200 + csp = r.headers["content-security-policy"] + # 'none' by default is what denies script-src and connect-src both, so + # the script neither runs nor could reach anywhere if it did. + assert "default-src 'none'" in csp + assert "script" not in csp # nothing re-permits it + assert r.headers["x-content-type-options"] == "nosniff" + + def test_the_policy_still_allows_an_ordinary_icon(self): + """Blocking script must not block SVG that legitimately styles itself.""" + csp = FAVICON_RESPONSE_HEADERS["Content-Security-Policy"] + assert "style-src 'unsafe-inline'" in csp + assert "img-src data:" in csp + + def test_the_route_create_app_registers_sends_them(self, tmp_path, monkeypatch): + """Asks the real route, not the copy the other tests build. + + A hand-written route cannot notice the headers being dropped from the one + ``create_app`` actually registers, which is the drift that would matter. + """ + import asyncio + + import nerve.config as cfgmod + from nerve.config import NerveConfig + from nerve.gateway.server import create_app + + ws = _ws(tmp_path) + (ws / "config" / "favicon.svg").write_text(self._SVG_WITH_SCRIPT) + config = NerveConfig() + config.workspace = ws + monkeypatch.setattr(cfgmod, "_config", config, raising=False) + + endpoint = next( + r.endpoint for r in create_app().routes + if getattr(r, "path", None) == "/favicon.ico" + ) + response = asyncio.run(endpoint()) + assert response.media_type == "image/svg+xml" + assert "default-src 'none'" in response.headers["content-security-policy"] + assert response.headers["x-content-type-options"] == "nosniff" + + def test_raster_formats_get_the_headers_too(self, tmp_path, monkeypatch): + """nosniff is for these: a .png whose bytes are HTML. + + Without it the browser may sniff past the declared image type and render + the markup, which puts the same script on the same origin. + """ + client, ws = _client(tmp_path, monkeypatch) + (ws / "config" / "favicon.png").write_text( + "" + ) + r = client.get("/favicon.ico") + assert r.headers["x-content-type-options"] == "nosniff" + assert r.headers["content-type"] == "image/png" + assert "default-src 'none'" in r.headers["content-security-policy"] + + +@pytest.fixture +def built_frontend(): + """A minimal ``web/dist`` so ``create_app`` registers the SPA catch-all. + + Without one the catch-all is never registered, and an assertion about + ordering against it passes because neither route is there to compare — which + is the failure the ordering test exists to catch. ``web/dist`` is gitignored, + so creating it dirties nothing; a real build is left alone. + """ + import nerve.gateway.server as server_mod + + dist = Path(server_mod.__file__).parent.parent.parent / "web" / "dist" + if dist.exists(): + yield dist + return + (dist / "assets").mkdir(parents=True) + (dist / "index.html").write_text("built") + try: + yield dist + finally: + shutil.rmtree(dist, ignore_errors=True) + + +class TestRegistrationOrder: + def test_favicon_is_matched_before_the_spa_catch_all(self, built_frontend): + """The catch-all answers everything, so order is what makes this work. + + Asserted against the real app, because the failure is silent: + /favicon.ico goes back to returning 200 with index.html in it. + """ + from nerve.gateway.server import create_app + + paths = [getattr(r, "path", None) for r in create_app().routes] + assert "/favicon.ico" in paths, paths + # Guards the fixture as much as the app: without the catch-all present + # the comparison below would be vacuous. + assert "/{path:path}" in paths, paths + assert paths.index("/favicon.ico") < paths.index("/{path:path}") + + def test_the_route_needs_no_authentication(self): + """A browser requests a favicon before the user has a token.""" + from nerve.gateway.server import create_app + + for route in create_app().routes: + if getattr(route, "path", None) == "/favicon.ico": + assert not getattr(route, "dependencies", []), route.dependencies + return + pytest.fail("no /favicon.ico route registered") diff --git a/web/index.html b/web/index.html index 874dadd2..954271f9 100644 --- a/web/index.html +++ b/web/index.html @@ -3,6 +3,9 @@ + + Nerve