From f2915bc7b1550b80cadfa36f5910223106cb3bfe Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 1 Sep 2026 20:03:03 +0200 Subject: fix(hub): send a CSP and protective headers on every response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SPA shell and its assets went out with no Content-Security-Policy and no X-Content-Type-Options / Referrer-Policy / X-Frame-Options — so an injection that reached the SPA (rendered third-party OpenGraph data, a federated group name, chat content) had nothing stopping it from loading more code or exfiltrating to any host, and the page could be framed by any site. A middleware in `create_app` now adds all four to every response. `webapp.CSP` is deliberately the *same* policy the desktop client's protocol handler already enforces on these exact UI files, plus the two reCAPTCHA hosts the sign-up widget needs: `default-src 'none'`, `script-src 'self' 'wasm-unsafe-eval' ` (the hub's own origin is not a script source — T3), `style-src 'self' 'unsafe-inline'` (htm/preact inline `style=` only, nothing executes), `connect-src 'self' https: wss:`, `frame-ancestors 'none'`, `base-uri 'none'`, `form-action 'none'`. The shell's dead `` is removed (nothing has ever read it) so `script-src` needs no inline allowance. Needs verification against the running SPA — a mis-tuned CSP shows as a blank page — but it matches a policy already proven with these files under Electron. Second-review L5 / third-review M5. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG --- packages/meshbay-hub/src/meshbay_hub/api/webapp.py | 31 ++++++++++- packages/meshbay-hub/src/meshbay_hub/app.py | 17 +++++- .../meshbay-hub/tests/test_security_headers.py | 65 ++++++++++++++++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 packages/meshbay-hub/tests/test_security_headers.py diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py index 0821809..6dfd3ed 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py @@ -22,7 +22,8 @@ STATIC_DIR = Path(__file__).parent.parent / "static" router = APIRouter(tags=["webapp"]) # Assets the shell pulls in, in load order. Everything else is imported by -# app.js and rides on the same query string via window.__MB_ASSET_V. +# app.js from a relative path, which inherits the `/a//` prefix the shell +# loaded app.js under — so the whole module graph moves together. # Every module the page loads. A file missing from here is a file whose change # does not move the URL, so a browser holding the old one never asks for it — # which is the failure this list exists to prevent, and it is silent. @@ -77,6 +78,33 @@ ASSET_V = _asset_version() _NO_STORE = {"Cache-Control": "no-store"} +# Content-Security-Policy for the whole hub, applied by a middleware in app.py. +# +# This is the *same* policy the desktop client's protocol handler already sends +# for these exact UI files (`meshbay-client/src/main.js`), plus the two reCAPTCHA +# hosts the sign-up widget loads its script, challenge iframe and images from. +# `'unsafe-inline'` is style-only — htm/preact set inline `style=` attributes +# everywhere; nothing inline executes, and the shell below carries no inline +# ` diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py index 76d7ec0..2daa55b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/app.py +++ b/packages/meshbay-hub/src/meshbay_hub/app.py @@ -35,7 +35,7 @@ from meshbay_hub.api.relay import router as relay_router from meshbay_hub.api.signaling import router as signaling_router from meshbay_hub.api.admin import router as admin_router from meshbay_hub.api.notifications import router as notifications_router -from meshbay_hub.api.webapp import router as webapp_router, STATIC_DIR, ASSET_V +from meshbay_hub.api.webapp import router as webapp_router, STATIC_DIR, ASSET_V, CSP from meshbay_hub.api.middleware import limiter @@ -142,6 +142,21 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI: app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + @app.middleware("http") + async def _security_headers(request, call_next): + """ + Second-review L5, third-review M5: the SPA shell and its assets went out + with no CSP and no other protective headers. This adds them everywhere — + `webapp.CSP` is the same policy the desktop client already enforces on + these exact files. `setdefault` so a route that sets its own wins. + """ + response = await call_next(request) + response.headers.setdefault("Content-Security-Policy", CSP) + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + response.headers.setdefault("X-Frame-Options", "DENY") + return response + # Routers (webapp last — catches / before API routes) app.include_router(hub_router) app.include_router(users_router) diff --git a/packages/meshbay-hub/tests/test_security_headers.py b/packages/meshbay-hub/tests/test_security_headers.py new file mode 100644 index 0000000..b4d7e6d --- /dev/null +++ b/packages/meshbay-hub/tests/test_security_headers.py @@ -0,0 +1,65 @@ +""" +The hub sends a Content-Security-Policy and the other protective headers on +every response — the SPA shell, its assets, and the API alike. + +Second-review L5 / third-review M5: previously there were none, so an injection +that landed in the SPA (rendered third-party OG data, a federated group name, +chat content) had nothing stopping it from loading more code or exfiltrating. +""" + +import pytest +from meshbay_hub.api.webapp import CSP + + +def _directive(csp: str, name: str) -> str: + for part in csp.split(";"): + part = part.strip() + if part == name or part.startswith(name + " "): + return part + return "" + + +@pytest.mark.asyncio +async def test_the_spa_shell_carries_the_policy(client): + r = await client.get("/") + assert r.headers["content-security-policy"] == CSP + assert r.headers["x-content-type-options"] == "nosniff" + assert r.headers["x-frame-options"] == "DENY" + assert "referrer-policy" in r.headers + + +@pytest.mark.asyncio +async def test_the_api_carries_the_headers_too(client): + r = await client.get("/v1/health") + assert r.status_code == 200 + assert "content-security-policy" in r.headers + assert r.headers["x-content-type-options"] == "nosniff" + + +@pytest.mark.asyncio +async def test_even_a_404_carries_the_headers(client): + # The middleware runs on every response, so a probe for a missing path + # cannot be framed or content-sniffed either. + r = await client.get("/no/such/path") + assert r.status_code == 404 + assert r.headers["x-frame-options"] == "DENY" + + +def test_the_policy_is_locked_down_where_it_matters(): + assert "default-src 'none'" in CSP # covers object-src, etc. + assert _directive(CSP, "frame-ancestors") == "frame-ancestors 'none'" + assert _directive(CSP, "base-uri") == "base-uri 'none'" + + script = _directive(CSP, "script-src") + # The hub's own origin must not be able to serve executable script (T3): + # 'self' and the wasm token are fine, a bare `https:` scheme is not. + assert "'self'" in script and "'wasm-unsafe-eval'" in script + assert "https:" not in script.split() + + +def test_recaptcha_is_the_only_external_origin(): + hosts = {"https://www.google.com", "https://www.gstatic.com"} + for part in CSP.split(";"): + for tok in part.strip().split()[1:]: + if tok.startswith(("http://", "https://")): + assert tok in hosts, f"unexpected external origin in CSP: {tok}" -- cgit v1.2.3