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