From f085cc0a2a7fb368155c070e2f59a6e12c053053 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:09:05 +0200 Subject: [PATCH 1/5] fix(security): give the API host its own headers, and measure the CSP before hardening it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api.anyplot.ai is a separate origin with no nginx in front of it, so it inherited nothing from app/security-headers.conf — only /proxy/html set nosniff and a Referrer-Policy, on that one response. An outermost middleware now setdefaults both on every response. NOT X-Frame-Options: the SPA embeds /proxy/html cross-origin in an iframe, and SAMEORIGIN would break every interactive preview. Both /_health locations set an add_header without re-including the snippet, and nginx drops every inherited header in such a location — the rule the file states at the top, and the one place that had missed it. tests/unit/api/test_csp_policy.py found that one, and pins the rest: object-src and base-uri stay closed, report-to never sits beside report-uri (Chromium then reports nothing), the API headers are present and X-Frame-Options is not, and the sha256 hashes the policy holds in reserve still describe index.html's inline scripts. In reserve, not in force, for a measured reason. Mounted over the live production bundle through a local proxy, a hash-only script-src blocks exactly one script: the inline one Cloudflare JavaScript Detections injects at the edge, whose body carries a per-response ray id and therefore has no fixed hash. With 'unsafe-inline' its hidden iframe appears; with hashes it does not, and the console reads "The action has been blocked". Hardening would have silently cost bot detection on a site whose origin gate leans on the edge. The way out is a nonce — Cloudflare stamps its injected script with the nonce it parses from this header — which needs an nginx sub_filter no test here can prove. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3 --- CHANGELOG.md | 27 +++++ api/main.py | 32 ++++- app/nginx.conf | 6 + app/security-headers.conf | 49 +++++++- tests/unit/api/test_csp_policy.py | 195 ++++++++++++++++++++++++++++++ 5 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 tests/unit/api/test_csp_policy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0475c20e969..6217a349ebf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,6 +136,33 @@ aggregate instead: an italic *Catalog* line at the end of the version section an ### Fixed +- **The API host stamps its own security headers, `/_health` stops dropping the site's, + and the CSP is now guarded by a test that also explains why `script-src` still says + `'unsafe-inline'`** — api.anyplot.ai is a separate origin with no nginx in front of it, + so it inherited none of `app/security-headers.conf`: only `/proxy/html` set + `nosniff` and a `Referrer-Policy`, on that one response. An outermost middleware now + `setdefault`s both on every response, including CORS preflights, the origin gate's 403 + and the exception handlers' 500s — but deliberately **not** `X-Frame-Options`, because + the SPA embeds `/proxy/html` cross-origin in an iframe and `SAMEORIGIN` would break + every interactive preview. On the website, both `/_health` locations set an + `add_header` of their own without re-including the snippet, and nginx drops every + inherited header in such a location — the rule the file states at the top and the one + place that had missed it. Both were found by the new + `tests/unit/api/test_csp_policy.py`, which also pins that the CSP keeps `object-src + 'none'` and `base-uri 'self'`, that it never carries `report-to` beside `report-uri` + (measured in the sibling repo: Chromium then reports nothing at all), and that the + three sha256 hashes the policy holds in reserve still describe `app/index.html`'s + inline scripts. Those hashes are in reserve rather than in force for a measured + reason: mounted over the live production bundle through a local proxy, a hash-only + `script-src` blocks exactly one script — the inline one **Cloudflare JavaScript + Detections injects at the edge**, whose body carries a per-response ray id and so has + no fixed hash. With `'unsafe-inline'` its hidden iframe appears, with hashes it does + not and the console reads "The action has been blocked". Hardening would have silently + cost bot detection on a site whose origin gate leans on the edge; the way out is a + nonce (Cloudflare stamps its injected script with the nonce it parses from this + header), which needs an nginx `sub_filter` no test here can prove. All of it is + written down at the directive it explains. (#PRNUM) + - **The IndexNow workflow no longer waits eight minutes behind an edge 403** — its key-file readiness loop treated every non-200 as "not deployed yet"; a GitHub runner that Cloudflare's bot management answers with 403 would have slept the full budget on diff --git a/api/main.py b/api/main.py index d69e48b8f80..b1b08d379e8 100644 --- a/api/main.py +++ b/api/main.py @@ -166,7 +166,7 @@ async def lifespan(app: FastAPI): # `@app.middleware` both wrap what is already there — so reading this file from # here down gives the order a request actually travels, in reverse: # -# cache headers → CORS → origin gate → bot counter → gzip → router +# security headers → cache headers → CORS → origin gate → bot counter → gzip → router # # (`HeadAsGetMiddleware` and `MCPTrailingSlashMiddleware` wrap the whole app # further out still; both only rewrite the scope.) @@ -286,6 +286,36 @@ async def add_cache_headers(request: Request, call_next): return response +# Added LAST, so it is the OUTERMOST http middleware and every response passes +# back through it — including CORS preflights, the origin gate's 403 and the +# exception handlers' 500s. +@app.middleware("http") +async def add_security_headers(request: Request, call_next): + """Stamp the two host-level security headers the API host was missing. + + `app/security-headers.conf` gives the website these, but api.anyplot.ai is + a separate origin with its own nginx-less delivery, and it served none of + them: only `/proxy/html` set a pair by hand, on that one response. The two + that belong on every API response: + + * `nosniff` — the API returns JSON, PNG and (on /proxy/html) HTML from the + same host, so content-type sniffing is exactly the confusion to forbid. + * `Referrer-Policy` — the same value the website sends, so a link followed + out of an API-served page leaks no path. + + Deliberately NOT `X-Frame-Options`: the SPA embeds `/proxy/html` in an + iframe from a different origin (`frame-src https://api.anyplot.ai` in the + site's CSP), and `SAMEORIGIN` would break every interactive plot preview. + + `setdefault`, so a route that has a reason to say something else — as + `/proxy/html` does — keeps its own value. + """ + response: Response = await call_next(request) + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + return response + + # Mount MCP server for AI assistant integration app.mount("/mcp", mcp_http_app) diff --git a/app/nginx.conf b/app/nginx.conf index 05aba16f267..b4c3ad6405a 100644 --- a/app/nginx.conf +++ b/app/nginx.conf @@ -307,6 +307,10 @@ server { access_log off; return 200 "OK"; add_header Content-Type text/plain; + # This location's own add_header drops every inherited one, which is + # the rule the top of security-headers.conf states and the one place in + # this file that had missed it (found by tests/unit/api/test_csp_policy.py). + include /etc/nginx/security-headers.conf; } # Proxy sitemap.xml to backend API (dynamic generation) @@ -459,6 +463,8 @@ server { access_log off; return 200 "OK"; add_header Content-Type text/plain; + # Same as the main block: an own add_header drops the inherited ones. + include /etc/nginx/security-headers.conf; } location = /sitemap.xml { diff --git a/app/security-headers.conf b/app/security-headers.conf index ef73e7ab79c..2e3c38e2b5f 100644 --- a/app/security-headers.conf +++ b/app/security-headers.conf @@ -7,9 +7,54 @@ # location, re-include this file there. # # CSP notes (must not break the SPA — see app/index.html and app/src): -# - script-src 'unsafe-inline': index.html ships inline scripts (theme -# resolver, Eruda loader, Plausible stub); no nonce infra for a static file. +# - script-src 'unsafe-inline': index.html ships three executable inline +# scripts (theme resolver, Eruda loader, Plausible stub), and a FOURTH one +# arrives that this repository does not write — see the block below. # - script-src cdn.jsdelivr.net: on-device debug console (Eruda) behind ?debug=1. +# +# Why script-src still says 'unsafe-inline' (measured 2026-09-03) +# --------------------------------------------------------------- +# Replacing 'unsafe-inline' with the sha256 of each inline script is the +# obvious hardening — index.html is static, so its scripts are fixed at build +# time, and `yarn build` was verified to copy them through byte-for-byte. The +# three hashes are recorded below and pinned by tests/unit/api/test_csp_policy.py +# so they never go stale. +# +# They cannot be ENFORCED yet. Cloudflare JavaScript Detections injects an +# inline script into every HTML response at the edge, after nginx, and its body +# carries a per-response ray id and timestamp — so its hash differs on every +# request and cannot be listed here. The whole policy was mounted over the LIVE +# production bundle through a local proxy and loaded twice, once with each +# script-src: +# +# 'unsafe-inline' → Cloudflare's script runs (its hidden iframe appears) +# hashes only → "Executing inline script violates … The action has +# been blocked", no iframe, no JS-detection signal +# +# Exactly one script is blocked, and it is the edge's. Shipping the hash policy +# would silently degrade bot detection on a site whose origin gate leans on the +# edge — so it is not shipped, and 'unsafe-inline' is NOT joined by hashes +# either: a browser ignores 'unsafe-inline' as soon as a hash is present, so the +# two together are the same breakage wearing a stricter-looking policy. +# +# The way out is a NONCE, not a hash. Cloudflare parses this response header +# and stamps its own injected script with the nonce it finds there (their +# JavaScript Detections docs say so explicitly, and recommend it over +# 'unsafe-inline'). That needs nginx to mint one per request and rewrite +# index.html's ``, so a single +# re-indent invalidates one. The test recomputes them from index.html on every +# run, which is what keeps this block honest while it waits. # - style-src 'unsafe-inline': MUI/emotion inject inline styles. # - img/font/connect storage.googleapis.com: plot previews + MonoLisa fonts on GCS. # - img/connect/frame api.anyplot.ai: API calls, og images, interactive-preview diff --git a/tests/unit/api/test_csp_policy.py b/tests/unit/api/test_csp_policy.py new file mode 100644 index 00000000000..19705f5f795 --- /dev/null +++ b/tests/unit/api/test_csp_policy.py @@ -0,0 +1,195 @@ +"""The delivery-side security policy, held against the files it describes. + +`app/security-headers.conf` is a string in an nginx include. Nothing compiles +it, nothing imports it, and three things in it can drift silently — each of +which has cost someone a day somewhere: + +1. **The inline-script hashes.** `script-src` cannot enforce them yet — + Cloudflare JavaScript Detections injects a fourth inline script at the edge + whose body changes per response (measured 2026-09-03, the reasoning is in + `security-headers.conf`) — so the file records them in a comment instead, + ready for the day a nonce or a zone setting makes the switch possible. A + recorded hash that no longer matches its script is worse than none: it looks + like readiness. This recomputes them on every run. + +2. **nginx's `add_header` inheritance.** A location with any `add_header` of + its own drops every inherited one. `app/nginx.conf` therefore re-includes + the snippet in each such location, and forgetting that in a new location is + invisible in review and invisible in the browser until someone checks that + one URL. + +3. **The API host's own headers.** api.anyplot.ai is a separate origin with no + nginx in front of it, so nothing there inherits anything from the website. + +The nginx parse is deliberately crude — a brace counter over one file we write +ourselves, not a config parser. It only has to be right about this file. +""" + +from __future__ import annotations + +import base64 +import hashlib +import re +from pathlib import Path + +from fastapi.testclient import TestClient + +from api.main import app + + +ROOT = Path(__file__).resolve().parents[3] +INDEX_HTML = ROOT / "app" / "index.html" +HEADERS_CONF = ROOT / "app" / "security-headers.conf" +NGINX_CONF = ROOT / "app" / "nginx.conf" + +INCLUDE_LINE = "include /etc/nginx/security-headers.conf;" + +# Comments are stripped BEFORE the script scan. index.html documents its own +# Eruda loader with the words `Plain ", re.DOTALL | re.IGNORECASE) +_TYPE = re.compile(r"""type\s*=\s*["']?([^"'\s>]+)""", re.IGNORECASE) +# A ` misses ``, which HTML permits — and a missed close swallows the rest of the document into one script body and hashes that. This parses a file people edit, so the strictness is earned. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3 --- tests/unit/api/test_csp_policy.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit/api/test_csp_policy.py b/tests/unit/api/test_csp_policy.py index 19705f5f795..deabb6d0e75 100644 --- a/tests/unit/api/test_csp_policy.py +++ b/tests/unit/api/test_csp_policy.py @@ -49,7 +49,11 @@ # regex that reads that as a tag hashes the comment prose instead of the # script — silently, and with a hash that looks perfectly plausible. _COMMENT = re.compile(r"", re.DOTALL) -_SCRIPT = re.compile(r"[^>]*)>(?P.*?)", re.DOTALL | re.IGNORECASE) +# ``, not ``: HTML lets whitespace sit before the `>` of a +# closing tag, and a regex that misses `` would silently swallow the +# rest of the document into one "script body" and hash that. CodeQL's +# py/bad-tag-filter says so, and it is right — this parses a file people edit. +_SCRIPT = re.compile(r"[^>]*)>(?P.*?)", re.DOTALL | re.IGNORECASE) _TYPE = re.compile(r"""type\s*=\s*["']?([^"'\s>]+)""", re.IGNORECASE) # A `: HTML lets whitespace sit before the `>` of a -# closing tag, and a regex that misses `` would silently swallow the -# rest of the document into one "script body" and hash that. CodeQL's -# py/bad-tag-filter says so, and it is right — this parses a file people edit. -_SCRIPT = re.compile(r"[^>]*)>(?P.*?)", re.DOTALL | re.IGNORECASE) +# The closing tag is ``, and then +# anything up to the first `>` — which is what a browser accepts and what +# CodeQL's py/bad-tag-filter insists on (``, ``). A +# regex that missed one of those would swallow the rest of the document into a +# single "script body" and hash that, silently. The lookahead is what keeps +# `` from counting as a close. +_SCRIPT = re.compile(r"[^>]*)>(?P.*?)])[^>]*>", re.DOTALL | re.IGNORECASE) _TYPE = re.compile(r"""type\s*=\s*["']?([^"'\s>]+)""", re.IGNORECASE) # A