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.
This commit is contained in:
maziggy
2026-05-23 12:28:58 +02:00
parent 4686d108ef
commit 4096d8d6bd
3 changed files with 95 additions and 4 deletions
+1
View File
@@ -25,6 +25,7 @@ All notable changes to Bambuddy will be documented in this file.
- **PyJWT CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): permanently ignored in pip-audit** — Advisory is disputed by the PyJWT maintainers, with the advisory description literally noting *"this is disputed by the Supplier because the key length is chosen by the application that uses the library."* `fix_versions=[]` on the advisory confirms no PyJWT patch exists or will exist. Bambuddy is not affected: `backend/app/core/auth.py:184` auto-generates secrets via `secrets.token_urlsafe(64)` (~86 chars of entropy, far above any sane minimum) and the file-loaded path at `:177` rejects secrets shorter than 32 chars. Added a permanent `--ignore-vuln CVE-2025-45768` to `.github/workflows/security.yml` with an inline comment citing the file:line evidence so a future maintainer reviewing the ignore list sees why it's load-bearing. Also dropped the stale `--ignore-vuln CVE-2026-4539` for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified: `pip-audit --ignore-vuln CVE-2025-45768` alone reports clean).
### Fixed
- **Cloudflare-fronted Bambuddy no longer needs an `unsafe-inline` override to load (#1460 follow-up, reported by @Soopahfly)** — A Bambuddy instance behind Cloudflare logged an inline-script CSP violation on every page load: Cloudflare's bot-detection script (`/cdn-cgi/challenge-platform/scripts/jsd/main.js`) is injected into the HTML on the edge with a hash that changes per request, so it can never be allowlisted by `script-src` hash. The contributor's workaround was to relax `script-src` to `'unsafe-inline'` in their Nginx Proxy Manager — which works but defeats most of the CSP. **Fix**: the SPA CSP now stamps a fresh per-request **nonce** into `script-src` (`'self' 'nonce-<base64>'`). Per [Cloudflare's documented behaviour](https://developers.cloudflare.com/cloudflare-challenges/challenge-types/javascript-detections/#if-you-have-a-content-security-policy-csp), when a nonce is present in the CSP header Cloudflare clones the same nonce onto its injected `<script>` and the inline script passes without `'unsafe-inline'`. Bambuddy's own `index.html` has had no inline scripts since the SW registration moved to `/sw-register.js` (#1460 first PR), so no HTML body rewriting is needed — `'self'` continues to cover every script the app ships. Implemented via a 16-byte `secrets.token_urlsafe()` nonce computed per request in `security_headers_middleware`. **Separately**, `/manifest.json`, `/sw.js` and `/sw-register.js` are now registered with `@app.api_route(methods=["GET", "HEAD"])` instead of `@app.get` — a plain `curl -I https://host/manifest.json` (and several uptime scanners) HEAD-probe these routes and were getting `405 Method Not Allowed`, which surfaced in the issue as an apparent manifest-server bug. **Tests**: 3 new in `test_security_headers.py` — `'nonce-…'` is stamped into the SPA `script-src` directive while `'self'` remains and `'unsafe-inline'` does not; the nonce is fresh per request across 5 sequential calls (collision probability ~0); HEAD on `/manifest.json`, `/sw.js`, `/sw-register.js` never returns 405. 22 security-header tests green; backend ruff clean.
- **Insufficient-filament pre-print warning now fires on every dispatch path (#1496, reported by @needo37)** — The "Pre-print checks now also warn when the spool has insufficient material" guard from #720 only fired on the `PrintModal` submit path. Two other queue-dispatch paths bypassed it entirely: the green ▶ Play button on a staged (`manual_start`) queue row called `POST /queue/{id}/start`, which only flipped the manual_start flag with no filament check; and the Virtual Printer queue-mode intake (`virtual_printer/manager._add_to_print_queue`) parsed per-slot requirements for *type* matching only — never weight. With `auto_dispatch=True` the scheduler would then dispatch unsupervised onto a doomed-to-fail spool. **Fix**: extracted the per-slot deficit calculation into a single backend helper (`backend/app/services/filament_deficit.py`) that both the route and the dispatch scheduler call against live spool state. Works for internal-inventory mode (`SpoolAssignment` → `Spool.label_weight - weight_used`) and Spoolman mode (`SpoolmanSlotAssignment` → `SpoolmanClient.get_spool`); Spoolman unreachability returns no deficit rather than wedging the queue. The `disable_filament_warnings` setting is honoured at the service boundary. `POST /queue/{id}/start` now returns `409 {detail: {code: 'insufficient_filament', deficit: [...]}}` when short; the `?skip_filament_check=true` query param is the "Print Anyway" bypass. The dispatch scheduler runs the same check just before each `_start_print` call: a deficit promotes the item to `manual_start=True` + `filament_short=True` (so the user must consciously click ▶) and a previously-flagged item whose spool was swapped to one with enough material clears the flag automatically on the next tick. A new `filament_short` boolean column on `print_queue` carries the flag; the queue row now renders a yellow "Insufficient filament for the assigned spool" badge when set, and the ▶ button catches the 409, opens an `Insufficient Filament / Print Anyway` confirm modal showing each shorted slot's required-vs-remaining grams, and on confirm re-issues the start with the skip flag. Migration is idempotent and branches on `is_sqlite()` for the `BOOLEAN DEFAULT` syntax. **Tests**: 8 in `test_filament_deficit.py` (deficit + sufficient + missing mapping + no printer + disabled-warnings + no-assignment + missing 3MF + multi-slot only-shorted-returned), 4 in `test_scheduler_filament_deficit.py` (block-on-deficit, clear-stale-flag, no-deficit no-op, helper-exception doesn't wedge), 2 new in `test_print_queue_api.py` (`/start` returns 409 + structured payload, `?skip_filament_check=true` bypasses), and 2 frontend tests in `QueuePage.test.tsx` (badge renders on flagged row, ▶ click → 409 → modal → retry with `skip_filament_check=true`). 3392 unit + 63 print-queue integration green; backend ruff clean; frontend build + i18n parity (9 locales × 4979 keys) clean.
- **File Manager "All Files" view showed nothing when every file lived in a subfolder (#1499)** — The sidebar entry was meant to list every file across the library but instead returned only files at the library root, so a library with two folders and three files (all nested) appeared empty. Cause was an inverted boolean on the React Query call: `getLibraryFiles(selectedFolderId, selectedFolderId === null)` passed `include_root=true` for the "All Files" selection, which on the backend (`library.py` `list_files`) means *root files only* — the opposite of what the UI wanted. **Fix**: pass `include_root=false` for "All Files" so the backend returns every active file across folders (it remains a no-op when a specific folder is selected — `folder_id` takes precedence). A new vitest regression case renders the page with one root file and one nested file and asserts both appear, and that the request goes out with `include_root=false`. 48/48 FileManagerPage tests green; frontend build clean.
- **Archive filament colour now reflects the assigned inventory spool, not the slicer's 3MF (#1494, reported by @IndividualGhost1905)** — A user added a `#000000` black filament to the built-in inventory, assigned it to the printer, and printed from the desktop slicer; the print, AMS and inventory all showed it as black and the correct spool's weight decremented — but the resulting archive (and the Color Distribution graph) showed `#161616`. Root cause is two independent colour sources: an archive's `filament_color` is parsed verbatim from the print job's 3MF (`archive.py` `_extract_filament_info` reads `filament_colour` from `project_settings.config`), which carries the *slicer's* filament-slot colour — a value the user picks separately from the exact hex they curate on the Bambuddy inventory spool. The two are "close but not equal" (slicer near-black `#161616` vs inventory `#000000`), which is exactly the "always a similar colour, never an unrelated one" pattern the report describes. **Fix**: once usage tracking has resolved the print's filament slots to inventory spools, the spool colours are authoritative — `_track_from_3mf` (built-in inventory) and `report_usage` (Spoolman mode) now overwrite the archive's `filament_color` with the slot-ordered, de-duplicated colours of the matched spools. The rewrite is **all-or-nothing**: it only applies when *every* used slot resolved to a spool that carries a colour, so a partially-mapped multi-colour print never silently loses the unmatched slots' colours (the 3MF value is kept). Shipped for both inventory modes in the same drop — built-in spools read `Spool.rgba`, Spoolman spools read the spool's `filament.color_hex` (fetched for tag-less slot-assignment matches). New helpers `_spool_color_to_hex` / `_archive_colors_from_spools` in `usage_tracker.py`, reused by `spoolman_tracking.py` via `_apply_spool_colors_to_archive`. **Tests**: 12 new in `test_usage_tracker.py` (hex normalisation, the all-or-nothing slot-colour rule across single/multi/partial/no-colour/AMS-fallback cases, and end-to-end that a `#000000` spool rewrites a `#161616` archive) + 4 in `test_spoolman_tracking.py` (the Spoolman-mode rewrite, empty/partial/missing-archive no-ops). 70 usage + Spoolman tracking tests green; backend ruff clean.
+19 -4
View File
@@ -3,6 +3,7 @@ import logging
import mimetypes as _mimetypes
import os
import posixpath
import secrets
import time
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
@@ -5147,6 +5148,16 @@ def _frame_ancestors(default_value: str) -> str:
@app.middleware("http")
async def security_headers_middleware(request, call_next):
"""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.headers["X-Content-Type-Options"] = "nosniff"
# X-Frame-Options is the legacy cross-origin embedding control. Modern
@@ -5199,7 +5210,7 @@ async def security_headers_middleware(request, call_next):
else:
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self'; "
f"script-src 'self' 'nonce-{csp_nonce}'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob:; "
"media-src 'self' blob:; "
@@ -5501,7 +5512,11 @@ async def health_check():
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():
"""Serve PWA manifest."""
manifest_file = app_settings.static_dir / "manifest.json"
@@ -5510,7 +5525,7 @@ async def serve_manifest():
return {"error": "Manifest not found"}
@app.get("/sw.js")
@app.api_route("/sw.js", methods=["GET", "HEAD"])
async def serve_service_worker():
"""Serve service worker."""
sw_file = app_settings.static_dir / "sw.js"
@@ -5523,7 +5538,7 @@ async def serve_service_worker():
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():
"""Serve the service-worker registration bootstrap script.
@@ -193,3 +193,78 @@ async def test_other_security_headers_unchanged(async_client: AsyncClient, monke
resp = await async_client.get("/api/v1/auth/status")
assert resp.headers.get("X-Content-Type-Options") == "nosniff"
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"