Просмотр исходного кода

fix(csp): nonce-based script-src so Cloudflare-injected scripts pass (#1460 follow-up)

  Behind Cloudflare, the bot-detection script CF injects into every HTML
  response carries a hash that rotates per request, so it can never be
  allowlisted by hash. Reporters with CF in front had to relax their NPM
  CSP to 'unsafe-inline' as a workaround.

  Per Cloudflare's documented behaviour, when a nonce is present in the
  page's script-src, CF clones it onto its injected <script>. The SPA CSP
  now stamps a fresh per-request nonce via secrets.token_urlsafe(16),
  keeping 'self' for our own scripts (index.html has had no inline scripts
  since the SW registration moved to /sw-register.js in the original
  #1460 PR), so no HTML body rewriting is needed.

  Also folded in: /manifest.json, /sw.js and /sw-register.js now accept
  HEAD as well as GET, so `curl -I` and uptime scanners stop returning
  405 on those routes - a separate red herring during this issue's
  debugging.

  Tests: 3 new in test_security_headers.py - 'nonce-' token stamped into
  SPA script-src while 'self' remains and 'unsafe-inline' does not; nonce
  is fresh per request across 5 sequential calls; HEAD on the three PWA
  routes never returns 405. 22/22 security-header tests green; backend
  ruff clean.
maziggy 5 дней назад
Родитель
Сommit
4096d8d6bd
3 измененных файлов с 94 добавлено и 4 удалено
  1. 0 0
      CHANGELOG.md
  2. 19 4
      backend/app/main.py
  3. 75 0
      backend/tests/integration/test_security_headers.py

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 19 - 4
backend/app/main.py

@@ -3,6 +3,7 @@ import logging
 import mimetypes as _mimetypes
 import mimetypes as _mimetypes
 import os
 import os
 import posixpath
 import posixpath
+import secrets
 import time
 import time
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
 from datetime import datetime, timedelta, timezone
 from datetime import datetime, timedelta, timezone
@@ -5147,6 +5148,16 @@ def _frame_ancestors(default_value: str) -> str:
 @app.middleware("http")
 @app.middleware("http")
 async def security_headers_middleware(request, call_next):
 async def security_headers_middleware(request, call_next):
     """Add standard HTTP security headers to every response."""
     """Add standard HTTP security headers to every response."""
+    # Per-request nonce stamped into `script-src` (#1460). On its own this
+    # changes nothing for Bambuddy's own pages — index.html has no inline
+    # scripts since the SW registration moved to /sw-register.js. The reason
+    # it's here is Cloudflare: a CF-fronted deployment has the bot-detection
+    # script injected into the HTML on the edge, with a fresh hash on every
+    # load (so hashes can't be allowlisted). When CF sees a nonce in our CSP,
+    # it clones the same nonce onto its injected <script>, and the inline
+    # script passes the policy without us needing 'unsafe-inline'. See
+    # https://developers.cloudflare.com/cloudflare-challenges/challenge-types/javascript-detections/#if-you-have-a-content-security-policy-csp
+    csp_nonce = secrets.token_urlsafe(16)
     response = await call_next(request)
     response = await call_next(request)
     response.headers["X-Content-Type-Options"] = "nosniff"
     response.headers["X-Content-Type-Options"] = "nosniff"
     # X-Frame-Options is the legacy cross-origin embedding control. Modern
     # X-Frame-Options is the legacy cross-origin embedding control. Modern
@@ -5199,7 +5210,7 @@ async def security_headers_middleware(request, call_next):
     else:
     else:
         response.headers["Content-Security-Policy"] = (
         response.headers["Content-Security-Policy"] = (
             "default-src 'self'; "
             "default-src 'self'; "
-            "script-src 'self'; "
+            f"script-src 'self' 'nonce-{csp_nonce}'; "
             "style-src 'self' 'unsafe-inline'; "
             "style-src 'self' 'unsafe-inline'; "
             "img-src 'self' data: blob:; "
             "img-src 'self' data: blob:; "
             "media-src 'self' blob:; "
             "media-src 'self' blob:; "
@@ -5501,7 +5512,11 @@ async def health_check():
     return {"status": "healthy"}
     return {"status": "healthy"}
 
 
 
 
-@app.get("/manifest.json")
+# GET + HEAD on the three PWA bootstrap routes (#1460). Scanners and a plain
+# `curl -I` use HEAD; FastAPI's @app.get only registers GET, so HEAD answers
+# with 405 Method Not Allowed and shows up as a "broken manifest" red herring
+# in deployment debugging.
+@app.api_route("/manifest.json", methods=["GET", "HEAD"])
 async def serve_manifest():
 async def serve_manifest():
     """Serve PWA manifest."""
     """Serve PWA manifest."""
     manifest_file = app_settings.static_dir / "manifest.json"
     manifest_file = app_settings.static_dir / "manifest.json"
@@ -5510,7 +5525,7 @@ async def serve_manifest():
     return {"error": "Manifest not found"}
     return {"error": "Manifest not found"}
 
 
 
 
-@app.get("/sw.js")
+@app.api_route("/sw.js", methods=["GET", "HEAD"])
 async def serve_service_worker():
 async def serve_service_worker():
     """Serve service worker."""
     """Serve service worker."""
     sw_file = app_settings.static_dir / "sw.js"
     sw_file = app_settings.static_dir / "sw.js"
@@ -5523,7 +5538,7 @@ async def serve_service_worker():
     return {"error": "Service worker not found"}
     return {"error": "Service worker not found"}
 
 
 
 
-@app.get("/sw-register.js")
+@app.api_route("/sw-register.js", methods=["GET", "HEAD"])
 async def serve_sw_register():
 async def serve_sw_register():
     """Serve the service-worker registration bootstrap script.
     """Serve the service-worker registration bootstrap script.
 
 

+ 75 - 0
backend/tests/integration/test_security_headers.py

@@ -193,3 +193,78 @@ async def test_other_security_headers_unchanged(async_client: AsyncClient, monke
         resp = await async_client.get("/api/v1/auth/status")
         resp = await async_client.get("/api/v1/auth/status")
         assert resp.headers.get("X-Content-Type-Options") == "nosniff"
         assert resp.headers.get("X-Content-Type-Options") == "nosniff"
         assert resp.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
         assert resp.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
+
+
+# ─── #1460: nonce-based script-src so Cloudflare-injected scripts pass ────
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_spa_csp_includes_per_request_script_nonce(async_client: AsyncClient):
+    """SPA CSP must stamp a fresh `'nonce-…'` token into script-src (#1460).
+
+    Cloudflare's bot-detection inline script is injected after our response
+    leaves the app, with a per-load hash that defeats hash allowlisting. When
+    a nonce is present in the CSP header, Cloudflare clones it onto its
+    injected `<script>` and the CSP passes without `'unsafe-inline'`.
+    """
+    import re
+
+    resp = await async_client.get("/api/v1/auth/status")
+    csp = resp.headers.get("Content-Security-Policy", "")
+    # Pull out the script-src directive (split on ';' so neighbours don't confuse us).
+    script_src = next(
+        (d.strip() for d in csp.split(";") if d.strip().startswith("script-src")),
+        "",
+    )
+    assert script_src, f"script-src directive missing: {csp!r}"
+    assert "'self'" in script_src, f"script-src must still allow 'self': {script_src!r}"
+    # Nonce token is `'nonce-<base64url>'` where the inner value is
+    # secrets.token_urlsafe(16) — about 22 url-safe chars.
+    assert re.search(r"'nonce-[A-Za-z0-9_-]{16,}'", script_src), (
+        f"script-src must include a 'nonce-…' token: {script_src!r}"
+    )
+    # We deliberately did NOT add 'unsafe-inline' alongside the nonce — that
+    # would defeat the purpose of using a nonce in the first place.
+    assert "'unsafe-inline'" not in script_src, (
+        f"script-src must not relax to 'unsafe-inline' on the SPA route: {script_src!r}"
+    )
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_spa_csp_nonce_changes_per_request(async_client: AsyncClient):
+    """A nonce is only useful if it's fresh per request (#1460)."""
+    import re
+
+    nonce_re = re.compile(r"'nonce-([A-Za-z0-9_-]+)'")
+
+    nonces = set()
+    for _ in range(5):
+        resp = await async_client.get("/api/v1/auth/status")
+        csp = resp.headers.get("Content-Security-Policy", "")
+        m = nonce_re.search(csp)
+        assert m, f"no nonce in CSP: {csp!r}"
+        nonces.add(m.group(1))
+    # 5 random 16-byte tokens collide with probability ~0 — anything less
+    # than all-5-distinct means we're handing out a stale/global nonce.
+    assert len(nonces) == 5, f"nonces should be per-request, got {nonces!r}"
+
+
+# ─── #1460: HEAD on PWA bootstrap routes (manifest / sw / sw-register) ───
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize("path", ["/manifest.json", "/sw.js", "/sw-register.js"])
+async def test_pwa_bootstrap_routes_accept_head(async_client: AsyncClient, path: str):
+    """Scanners and `curl -I` HEAD-probe these — must not 405 (#1460).
+
+    Previously these were `@app.get` only, so HEAD returned 405 Method Not
+    Allowed and looked like a manifest/SW server-side bug when debugging
+    Cloudflare-fronted deployments.
+    """
+    resp = await async_client.head(path)
+    # 200 if static asset is present in the test environment, 404 if it's
+    # not packaged in this checkout — but never 405.
+    assert resp.status_code != 405, f"HEAD {path} returned 405 — route must accept HEAD as well as GET"

Некоторые файлы не были показаны из-за большого количества измененных файлов