fix(security): add missing HTTP security headers across all surfaces (#549) - #641
Merged
Conversation
…549) UnderDefense pentest 3.4.2 flagged Trinity as missing the standard security header set. Most production headers were already in place via nginx security-headers.conf but three surfaces needed work: 1. FastAPI middleware (src/backend/main.py): API responses (Swagger, direct curl, /docs) lacked X-Frame-Options, Cross-Origin-Opener-Policy, and conditional Strict-Transport-Security. Added all three. CSP is intentionally NOT set on /api/* responses — they're JSON, not rendered, and a strict CSP would block legitimate Swagger UI interactions. 2. Vite dev server (src/frontend/vite.config.js): `npm run dev` served the SPA with zero security headers, so devs only saw CSP regressions in production. New configureServer plugin mirrors security-headers.conf (with the dev-only relaxation of script-src 'unsafe-inline' 'unsafe-eval' for Vite HMR + module preloads). HSTS deliberately NOT emitted in dev — would force the dev box's hostname to HTTPS in the browser HSTS cache and break subsequent HTTP work. 3. Production nginx (src/frontend/security-headers.conf + nginx.conf): added Strict-Transport-Security gated on X-Forwarded-Proto from the upstream TLS terminator (cloudflare / external LB). Implemented via `map $http_x_forwarded_proto $hsts_header` so the conditional is evaluated once per request. nginx skips emission when the mapped value is empty (default branch). The FastAPI HSTS check honours both request.url.scheme and X-Forwarded-Proto so it works whether uvicorn is direct-facing or behind a proxy with --proxy-headers. X-Frame-Options uses DENY on FastAPI (API responses should never be iframed) and the existing SAMEORIGIN on the frontend (SPA could legitimately be embedded in same-origin contexts). Cross-Origin-Embedder-Policy intentionally still omitted across the board: require-corp would break any future cross-origin resources and no SharedArrayBuffer use case justifies the cost. Tests in tests/test_security_headers.py extended with assertions for X-Frame-Options, COOP, HSTS-absent-on-HTTP, and HSTS-present-on- forwarded-HTTPS. All 5 pass against a live local stack. Verified manually: curl -I against backend (:8000), Vite dev (:8001), and nginx -t syntax check on the production config inside the Trinity docker network. Closes #549. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vybe
approved these changes
May 5, 2026
vybe
left a comment
Contributor
There was a problem hiding this comment.
Validated via /validate-pr — clean security fix, all three surfaces covered, tests pass. No blocking issues. Approving for merge.
vybe
pushed a commit
that referenced
this pull request
Jul 2, 2026
The Files-tab preview builds blob: URLs (URL.createObjectURL) and renders them
per type — text/download via fetch() (connect-src), <img> (img-src),
<video>/<audio> (media-src), and PDF <embed> (object-src + frame-src, since
Chrome renders the embedded PDF in an internal viewer frame). blob: is not
covered by 'self', so each directive must list it explicitly. Only img-src did,
so images previewed while text/media/PDF failed ("Failed to load text content" /
Chrome's "This content is blocked"). Latent since the CSP landed (#549/#641);
#784's Content-Disposition fix never touched the real cause, so it stayed broken
in every nginx-served build and under the dev Vite CSP alike.
- Add blob: to connect-src/media-src/object-src/frame-src in BOTH CSP copies
(security-headers.conf prod nginx + vite.config.js dev), kept in sync
- Add tests/unit/test_1400_csp_blob_preview.py: asserts blob: in each directive
across both files and that the two files stay in sync (fails on the old CSP)
- Document the bug class in docs/memory/learnings.md and the file-manager
feature flow
Verified locally across text, image (PNG+SVG), audio, and PDF previews.
Fixes #1400
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #549. UnderDefense pentest 3.4.2 flagged Trinity for missing the standard HTTP security header set. Production nginx already had most via
security-headers.conf; the gap was on three surfaces this PR addresses.Changes
src/backend/main.py)X-Forwarded-Proto: httpsorrequest.url.scheme == "https")src/frontend/vite.config.js)npm run devserved the SPA with nothingconfigureServerplugin mirrors prod with dev-only'unsafe-inline' 'unsafe-eval'onscript-srcfor Vite HMRsecurity-headers.conf+nginx.conf)map $http_x_forwarded_proto $hsts_header— fires only when upstream TLS terminator signals HTTPS, nginx skips emission on plain HTTPDesign choices
/api/*returns JSON, not rendered HTML. A strict CSP on JSON is meaningless and would block legitimate Swagger UI script execution from the docs route.mapcheck the upstreamX-Forwarded-Protoso it works whether uvicorn is direct-facing (--proxy-headers) or behind cloudflare/traefik/external-LB.require-corpwould break future cross-origin resources and no SharedArrayBuffer use case justifies the cost. Existing comment insecurity-headers.confdocuments this intentional choice.Server: uvicornalready suppressed via--no-server-headerflag in bothDockerfileCMD anddocker-compose.ymlcommand. No change needed.Tests
tests/test_security_headers.pyextended (now 5 tests, all pass against the live local stack):./.venv/bin/pytest tests/test_security_headers.py -v # 5 passed in 0.38stest_api_response_has_security_headersextended with X-Frame-Options + COOP assertionstest_server_header_strippedtest_cors_preflight_still_workstest_hsts_absent_on_plain_httptest_hsts_set_when_x_forwarded_proto_httpsManual verification
UI smoke (Vite dev frontend at http://localhost:8001) loaded without console errors after restart. Production CSP unchanged from current shipping config — only HSTS added.
Acceptance criteria
Serverheader stripped (already via--no-server-header; verified)curl -Ioutput on backend, Vite dev, and nginx (verified)X-Forwarded-Protoin both FastAPI and nginx)🤖 Generated with Claude Code