Skip to content

Commit 95b8691

Browse files
Merge remote-tracking branch 'origin/main' into infra/api-deploy-edges
2 parents 42f9bbd + 4b32715 commit 95b8691

7 files changed

Lines changed: 413 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,37 @@ aggregate instead: an italic *Catalog* line at the end of the version section an
142142

143143
### Fixed
144144

145+
- **The API host stamps its own security headers, `/_health` stops dropping the site's,
146+
and the CSP is now guarded by a test that also explains why `script-src` still says
147+
`'unsafe-inline'`** — api.anyplot.ai is a separate origin with no nginx in front of it,
148+
so it inherited none of `app/security-headers.conf`: only `/proxy/html` set
149+
`nosniff` and a `Referrer-Policy`, on that one response. An outermost middleware now
150+
`setdefault`s both on every response that leaves through the stack — CORS preflights, the
151+
origin gate's 403, an `HTTPException`'s 4xx — and the unhandled-500 handler stamps the
152+
same pair through the same helper, because `ServerErrorMiddleware` wraps every user
153+
middleware and builds that response outside the stack, which is the one exit a middleware
154+
cannot reach. Deliberately **not** `X-Frame-Options`, because
155+
the SPA embeds `/proxy/html` cross-origin in an iframe and `SAMEORIGIN` would break
156+
every interactive preview. On the website, both `/_health` locations set an
157+
`add_header` of their own without re-including the snippet, and nginx drops every
158+
inherited header in such a location — the rule the file states at the top and the one
159+
place that had missed it. Both were found by the new
160+
`tests/unit/api/test_csp_policy.py`, which also pins that the CSP keeps `object-src
161+
'none'` and `base-uri 'self'`, that a `report-to` group it names is actually defined by a
162+
`Reporting-Endpoints` header (reports to an undeclared group go nowhere, and nowhere reads
163+
exactly like "no violations"), and that the
164+
three sha256 hashes the policy holds in reserve still describe `app/index.html`'s
165+
inline scripts. Those hashes are in reserve rather than in force for a measured
166+
reason: mounted over the live production bundle through a local proxy, a hash-only
167+
`script-src` blocks exactly one script — the inline one **Cloudflare JavaScript
168+
Detections injects at the edge**, whose body carries a per-response ray id and so has
169+
no fixed hash. With `'unsafe-inline'` its hidden iframe appears, with hashes it does
170+
not and the console reads "The action has been blocked". Hardening would have silently
171+
cost bot detection on a site whose origin gate leans on the edge; the way out is a
172+
nonce (Cloudflare stamps its injected script with the nonce it parses from this
173+
header), which needs an nginx `sub_filter` no test here can prove. All of it is
174+
written down at the directive it explains. (#11213)
175+
145176
- **The IndexNow workflow no longer waits eight minutes behind an edge 403** — its
146177
key-file readiness loop treated every non-200 as "not deployed yet"; a GitHub runner
147178
that Cloudflare's bot management answers with 403 would have slept the full budget on

api/exceptions.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from fastapi.responses import JSONResponse
1111
from pydantic import BaseModel
1212

13+
from api.security_headers import stamp as stamp_security_headers
14+
1315

1416
logger = logging.getLogger(__name__)
1517

@@ -140,10 +142,17 @@ async def generic_exception_handler(request: Request, exc: Exception) -> JSONRes
140142
Never reflects the raw exception text back to clients — `str(exc)` can leak
141143
DSN fragments, table names, file-path traceback fragments, and other internal
142144
state. The full traceback goes to the server log instead.
145+
146+
Stamps the security headers itself. `ServerErrorMiddleware` wraps every user
147+
middleware, so this response is built OUTSIDE the http middleware stack and
148+
is the one exit `api/main.py`'s header middleware cannot reach (Copilot
149+
review). Same helper on both paths, so the two cannot drift.
143150
"""
144151
logger.exception("Unhandled exception on %s", request.url.path)
145-
return JSONResponse(
146-
status_code=500, content={"status": 500, "message": "Internal server error", "path": _public_path(request)}
152+
return stamp_security_headers(
153+
JSONResponse(
154+
status_code=500, content={"status": 500, "message": "Internal server error", "path": _public_path(request)}
155+
)
147156
)
148157

149158

api/main.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
from api.routers.plots import _refresh_filter_all # noqa: E402
4747
from api.routers.specs import _refresh_specs_list, _refresh_specs_map # noqa: E402
4848
from api.routers.stats import _refresh_stats # noqa: E402
49+
from api.security_headers import stamp as stamp_security_headers # noqa: E402
4950
from api.version import APP_VERSION # noqa: E402
5051
from core.config import settings # noqa: E402
5152
from core.constants import LANGUAGES_METADATA, LIBRARIES_METADATA # noqa: E402
@@ -166,7 +167,7 @@ async def lifespan(app: FastAPI):
166167
# `@app.middleware` both wrap what is already there — so reading this file from
167168
# here down gives the order a request actually travels, in reverse:
168169
#
169-
# cache headers → CORS → origin gate → bot counter → gzip → router
170+
# security headers → cache headers → CORS → origin gate → bot counter → gzip → router
170171
#
171172
# (`HeadAsGetMiddleware` and `MCPTrailingSlashMiddleware` wrap the whole app
172173
# further out still; both only rewrite the scope.)
@@ -286,6 +287,24 @@ async def add_cache_headers(request: Request, call_next):
286287
return response
287288

288289

290+
# Added LAST, so it is the OUTERMOST http middleware and every response that
291+
# leaves through the stack passes back through it — CORS preflights, the origin
292+
# gate's 403, an HTTPException's 4xx.
293+
#
294+
# It cannot be the only place, though. `ServerErrorMiddleware` wraps every user
295+
# middleware, so a route that RAISES makes `await call_next(request)` raise too
296+
# and the registered `Exception` handler's 500 is built outside this stack. That
297+
# path stamps the same headers itself, through the same helper
298+
# (`api/security_headers.py`, `api/exceptions.py::generic_exception_handler`).
299+
@app.middleware("http")
300+
async def add_security_headers(request: Request, call_next):
301+
"""Stamp the baseline security headers the API host was missing.
302+
303+
Which headers, and why not `X-Frame-Options`: `api/security_headers.py`.
304+
"""
305+
return stamp_security_headers(await call_next(request))
306+
307+
289308
# Mount MCP server for AI assistant integration
290309
app.mount("/mcp", mcp_http_app)
291310

api/security_headers.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""The two security headers every API response carries, in one place.
2+
3+
`app/security-headers.conf` gives the website its headers through nginx.
4+
api.anyplot.ai is a separate origin with no nginx in front of it, so it inherits
5+
none of them — and served none until this module existed, apart from the pair
6+
`/proxy/html` set by hand on that one response.
7+
8+
One place, because there are TWO exits from the app and only one of them is a
9+
middleware. Starlette's `ServerErrorMiddleware` wraps every user middleware, so
10+
when a route raises, `await call_next(request)` raises with it and the response
11+
the registered `Exception` handler builds is produced OUTSIDE the stack — an
12+
unhandled 500 would leave without headers while the middleware's docstring
13+
claimed otherwise (Copilot review). Both paths call `stamp` instead.
14+
15+
Deliberately NOT `X-Frame-Options`: the SPA embeds `/proxy/html` in an iframe
16+
from a different origin (`frame-src https://api.anyplot.ai` in the site's CSP),
17+
and `SAMEORIGIN` would break every interactive plot preview.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from typing import TypeVar
23+
24+
from starlette.responses import Response
25+
26+
27+
# So a caller that hands in a JSONResponse gets a JSONResponse back — the
28+
# exception handler's signature promises one, and a bare `Response` return would
29+
# make the helper the reason mypy fails there.
30+
ResponseT = TypeVar("ResponseT", bound=Response)
31+
32+
SECURITY_HEADERS = {
33+
# The API returns JSON, PNG and (on /proxy/html) HTML from the same host, so
34+
# content-type sniffing is exactly the confusion to forbid.
35+
"X-Content-Type-Options": "nosniff",
36+
# The same value the website sends, so a link followed out of an API-served
37+
# page leaks no path.
38+
"Referrer-Policy": "strict-origin-when-cross-origin",
39+
}
40+
41+
42+
def stamp(response: ResponseT) -> ResponseT:
43+
"""Add the baseline headers, keeping any a route set on purpose.
44+
45+
`setdefault`, so a response with a reason to say something else — as
46+
`/proxy/html` does — keeps its own value.
47+
"""
48+
for name, value in SECURITY_HEADERS.items():
49+
response.headers.setdefault(name, value)
50+
return response

app/nginx.conf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,10 @@ server {
307307
access_log off;
308308
return 200 "OK";
309309
add_header Content-Type text/plain;
310+
# This location's own add_header drops every inherited one, which is
311+
# the rule the top of security-headers.conf states and the one place in
312+
# this file that had missed it (found by tests/unit/api/test_csp_policy.py).
313+
include /etc/nginx/security-headers.conf;
310314
}
311315

312316
# Proxy sitemap.xml to backend API (dynamic generation)
@@ -459,6 +463,8 @@ server {
459463
access_log off;
460464
return 200 "OK";
461465
add_header Content-Type text/plain;
466+
# Same as the main block: an own add_header drops the inherited ones.
467+
include /etc/nginx/security-headers.conf;
462468
}
463469

464470
location = /sitemap.xml {

app/security-headers.conf

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,54 @@
77
# location, re-include this file there.
88
#
99
# CSP notes (must not break the SPA — see app/index.html and app/src):
10-
# - script-src 'unsafe-inline': index.html ships inline scripts (theme
11-
# resolver, Eruda loader, Plausible stub); no nonce infra for a static file.
10+
# - script-src 'unsafe-inline': index.html ships three executable inline
11+
# scripts (theme resolver, Eruda loader, Plausible stub), and a FOURTH one
12+
# arrives that this repository does not write — see the block below.
1213
# - script-src cdn.jsdelivr.net: on-device debug console (Eruda) behind ?debug=1.
14+
#
15+
# Why script-src still says 'unsafe-inline' (measured 2026-09-03)
16+
# ---------------------------------------------------------------
17+
# Replacing 'unsafe-inline' with the sha256 of each inline script is the
18+
# obvious hardening — index.html is static, so its scripts are fixed at build
19+
# time, and `yarn build` was verified to copy them through byte-for-byte. The
20+
# three hashes are recorded below and pinned by tests/unit/api/test_csp_policy.py
21+
# so they never go stale.
22+
#
23+
# They cannot be ENFORCED yet. Cloudflare JavaScript Detections injects an
24+
# inline script into every HTML response at the edge, after nginx, and its body
25+
# carries a per-response ray id and timestamp — so its hash differs on every
26+
# request and cannot be listed here. The whole policy was mounted over the LIVE
27+
# production bundle through a local proxy and loaded twice, once with each
28+
# script-src:
29+
#
30+
# 'unsafe-inline' → Cloudflare's script runs (its hidden iframe appears)
31+
# hashes only → "Executing inline script violates … The action has
32+
# been blocked", no iframe, no JS-detection signal
33+
#
34+
# Exactly one script is blocked, and it is the edge's. Shipping the hash policy
35+
# would silently degrade bot detection on a site whose origin gate leans on the
36+
# edge — so it is not shipped, and 'unsafe-inline' is NOT joined by hashes
37+
# either: a browser ignores 'unsafe-inline' as soon as a hash is present, so the
38+
# two together are the same breakage wearing a stricter-looking policy.
39+
#
40+
# The way out is a NONCE, not a hash. Cloudflare parses this response header
41+
# and stamps its own injected script with the nonce it finds there (their
42+
# JavaScript Detections docs say so explicitly, and recommend it over
43+
# 'unsafe-inline'). That needs nginx to mint one per request and rewrite
44+
# index.html's `<script>` tags with it — `sub_filter` plus `gzip_static off`
45+
# for the shell — which is a delivery change no test in this repo can prove and
46+
# no local nginx here can run. It is the open item; the alternative is turning
47+
# JavaScript Detections off in the zone, which is a security trade, not a fix.
48+
#
49+
# The hashes of app/index.html's three executable inline scripts, ready for the
50+
# day one of those two happens (JSON-LD blocks need none — a browser never
51+
# executes `type="application/ld+json"`, so CSP never asks):
52+
# 'sha256-4VdX7wfQgL9PnVFBkrDWBbPpiST1xriKljA5URM8DcM=' theme resolver
53+
# 'sha256-HfNBzShy4Q4W9GmmnPkcx36GlrZI5mkU15xjpyh1pmk=' Eruda loader
54+
# 'sha256-BiWO1y5gYRbSlOrPh1rJPXYnES50FX9PwJpfXfWJy0A=' Plausible stub
55+
# A hash covers the exact bytes between `<script>` and `</script>`, so a single
56+
# re-indent invalidates one. The test recomputes them from index.html on every
57+
# run, which is what keeps this block honest while it waits.
1358
# - style-src 'unsafe-inline': MUI/emotion inject inline styles.
1459
# - img/font/connect storage.googleapis.com: plot previews + MonoLisa fonts on GCS.
1560
# - img/connect/frame api.anyplot.ai: API calls, og images, interactive-preview

0 commit comments

Comments
 (0)