feat: add security response headers middleware (closes #98) #112
Reference in New Issue
Block a user
Delete Branch "fix/98-security-headers"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #98.
Adds
SecurityHeaders()tointernal/middleware/middleware.goand registers it in the global middleware stack ininternal/server/routes.go, immediately afterchimw.RequestIDand beforeLogging/CORS, so it applies to every route:/,/s/...,/.well-known/healthcheck,/health,/api/v1/status, and the/metricsgroup.Headers set on every response
Strict-Transport-Securitymax-age=31536000; includeSubDomainsContent-Security-PolicyX-Frame-OptionsDENYX-Content-Type-OptionsnosniffReferrer-Policyno-referrerPermissions-Policy()CSP, exactly as emitted:
Why this CSP
Verified against
internal/handlers/templates/dashboard.htmlandstatic/: the template has no<script>tags (the 30-second refresh is a<meta http-equiv="refresh">), no inlinestyle=, no inline event handlers, and no<img>. Its only subresource is<link rel="stylesheet" href="/s/css/tailwind.min.css">, served same-origin from the embedded FS, whichstyle-src 'self'permits.static/css/tailwind.min.csscontains nourl()and no@font-face, sofont-src 'none'is safe. With no JavaScript,script-src 'none'andconnect-src 'none'cost nothing.img-src 'self'is kept rather than'none'so a future same-origin/favicon.icois not blocked. Neitherunsafe-inlinenorunsafe-evalappears anywhere.frame-ancestors 'none'is the primary anti-framing control, withX-Frame-Options: DENYretained as the legacy fallback per policy.Two deliberate choices worth review attention:
r.TLS != nil. The service speaks plain HTTP behind a TLS-terminating proxy, andREPO_POLICIES.mdrequires the application itself to emit HSTS so the browser enforces HTTPS end to end.Referrer-Policy: no-referrerrather than thestrict-origin-when-cross-originbaseline. The policy and the issue both allow "or stricter"; the dashboard has no cross-origin navigation needs and its URL can name internal hosts, so leaking nothing is the better default.The headers are written before the request reaches the next handler, so they are present on error responses too, including panics recovered by
chimw.Recovererand 504s produced bychimw.Timeout.Tests
New external-package tests in
internal/middleware/middleware_test.go:TestSecurityHeaders— table-driven over all six headers, asserting exact values (expected values written out literally in the test rather than imported from the implementation, so a change to the middleware must be made deliberately in both places).TestSecurityHeadersCSPDirectives— asserts the CSP contains neitherunsafe-inlinenorunsafe-eval, and does containdefault-src 'self',script-src 'none',style-src 'self',frame-ancestors 'none'.TestSecurityHeadersOnErrorResponse— headers present on a 500.TestDashboardRendersWithSecurityHeaders— the realhandlers.HandleDashboard()wired through a chi router with the middleware: asserts HTTP 200, that the rendered body still references/s/css/tailwind.min.css, and that the emitted CSP is the expected one and permits that stylesheet.No DNS is involved anywhere in this change or its tests.
Verification
make check(test + lint + fmt-check) green:0 issues., full cold run 10.98s wall, well under the 20-second policy ceiling;internal/middlewaretests run in 0.006s.make fmtrun; result included in the commit..golangci.ymluntouched — sha256 still021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb. The golangci-lint commit pin is untouched.README.mdgains a "Security Headers" subsection under the HTTP API area documenting the six headers, the CSP, and the two rationale notes above; the architecture listing now mentions security headers.TODO.mdupdated in the same commit as the work.Out of scope
http.Servertimeouts, request body limits, rate limiting, and CORS scoping are tracked separately and are not touched here.Definition of done from #98, item by item:
SecurityHeaders()on*Middlewareininternal/middleware/middleware.gosetsStrict-Transport-Security: max-age=31536000; includeSubDomains, the CSP below,X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer, and aPermissions-Policydenying seventeen features including camera, microphone, and geolocation. Values live as package constants; the tests assert them literally rather than importing the constants, so the implementation and the expectation cannot drift silently.internal/server/routes.gocallss.router.Use(s.mw.SecurityHeaders())in the global stack, afterchimw.RequestIDand beforeLogging/CORS, so it covers/,/s/...static assets, both healthchecks,/api/v1/status, and the/metricsgroup (the group inherits global middleware; onlyMetricsAuthis group-scoped).r.TLScheck anywhere in the middleware; the header is set on every request regardless of scheme.internal/middleware/middleware_test.go(externalpackage middleware_test): a table-driven test over all six headers asserting exact values, a CSP directive test, a test that the headers survive a 500, andTestDashboardRendersWithSecurityHeaders, which serves the realhandlers.HandleDashboard()through a chi router with the middleware and asserts HTTP 200./s/css/tailwind.min.cssand that the emitted CSP carriesstyle-src 'self', which permits that same-origin stylesheet. The template loads no other subresource — no scripts, no inline styles, no images, no fonts.no-referrer. The architecture listing now names security headers among the middleware.make checkgreen,TODO.mdin the same commit — done. Single commite97a4e5, which contains the middleware, the route registration, the tests, the README, and theTODO.mdentry together.The exact CSP:
Two calls a reviewer may want to second-guess, both argued in the PR body:
Referrer-Policy: no-referrerinstead of thestrict-origin-when-cross-originbaseline (the issue allows "or stricter"), andimg-src 'self'rather than'none'so a future same-origin favicon is not blocked.Verification run:
make check—0 issues., all packages pass, 10.98s wall on a cold run (previous baseline ~7.8s; the delta is compile noise from a cold cache, the new tests themselves add 0.006s).make fmtwas run and its output is in the commit..golangci.ymlis untouched: sha256021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb. The golangci-lint commit pin is untouched. No DNS is exercised by this change, and no dependency was added — the six headers are plainw.Header().Set(...)calls.Out of scope and untouched:
http.Servertimeouts, request body limits, rate limiting, CORS scoping.Review: PASS
Independent review of head
e97a4e5againstmain@9347a28. Every claim in the PR body was re-verified against the code rather than taken on trust; the three that were most load-bearing (CSP safety, middleware coverage, hardcoded test expectations) all hold.Definition of done — item by item
internal/middleware/middleware.go:24-75. HSTSmax-age=31536000is exactly one year and carriesincludeSubDomains, meetingREPO_POLICIES.md:281-282. CSP hasdefault-src 'self'baseline and contains neitherunsafe-inlinenorunsafe-eval.X-Frame-Options: DENYplusframe-ancestors 'none'satisfies the "prefer frame-ancestors as primary control" clause atREPO_POLICIES.md:288.no-referreris strictly stricter than thestrict-origin-when-cross-originfloor.Permissions-Policydenies camera, microphone and geolocation among seventeen features. PASS.internal/server/routes.go:24. Coverage claim proven from chi v5.2.5 source, not assumed:mux.go:77-78documentsmx.handlerasmx.middlewares + mx.routeHTTP, i.e. the global chain runs to completion before any routing decision. ThereforeMount("/s", ...)(the barehttp.FileServer), theGroupwrapping/metrics,Route("/api/v1"), both healthchecks, and the 404 handler all inheritSecurityHeaders.MetricsAuthis group-scoped only and does not displace the global stack. PASS.r.TLScheck anywhere in the middleware. MatchesREPO_POLICIES.md:327-331. PASS.TestDashboardRendersWithSecurityHeadersrendering the realhandlers.HandleDashboard(). PASS. On the anti-tautology question: the expectations are genuinely independent, and structurally so — the implementation constants (hstsValue,cspValue, …) are unexported in packagemiddleware, and the test is externalpackage middleware_test, so it cannot import them even by accident. Mutating any header value in the implementation fails the suite.internal/handlers/templates/dashboard.html(370 lines): zero<script>, zero<style>blocks, zero inlinestyle=, zeroon*=handlers, zero<img>/<svg>/<iframe>/<object>, zero<form>(soform-action 'none'is free), zero<base>(sobase-uri 'none'is free), zerodata:URIs, zerobackground-image, and no favicon<link>. The sole subresource is<link rel="stylesheet" href="/s/css/tailwind.min.css">, same-origin, permitted bystyle-src 'self'.static/css/tailwind.min.css: zerourl(, zero@font-face, zero@import— sofont-src 'none'andimg-src 'self'block nothing real. The 30-second refresh is<meta http-equiv="refresh">, a navigation that no directive in this policy restricts. The page renders intact in a real browser. PASS.make checkgreen,TODO.mdsame commit — single commite97a4e5carries middleware, route registration, tests, README andTODO.mdtogether. PASS.Hard constraints
.golangci.ymlsha256021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb— unmodified, and absent from the diff entirely.c0d3ddc9cf3faa61a4e378e879ece580256d76e5intact in bothDockerfile:8andscript/bootstrap:14.go.mod/go.sumunchanged — no dependency added; the headers are plainw.Header().Set(...)calls.(closes #98).Attack findings
Permissions-Policysyntax — valid. Uses the modern structured-headerfeature=()form, comma-separated, not the deprecatedFeature-Policyfeature 'none'grammar. The header is functional, not decorative. Unrecognized feature names are ignored per-item by the spec, so no item risks discarding the whole header.WriteHeaderhazard — not reachable.SecurityHeaderswrites the entire header map before callingnext.ServeHTTP, so no security header is ever set on an already-committed response and none can be silently dropped. This is the correct construction.chimw.Recoverer/chimw.Timeout— correct despiteRecovererbeing registered first. The chain isRecoverer→RequestID→SecurityHeaders→Logging→CORS→Timeout→ handler. A handler panic unwinds throughSecurityHeaders, which has already populated the header map, soRecoverer'sWriteHeader(500)emits them.Timeoutsits insideSecurityHeaders, so its 504 carries them too. Same reasoning covers a CORS preflight short-circuit.state.NewForTest()filesystem risk — no exposure.HandleDashboard(internal/handlers/dashboard.go:52-81) only callsGetSnapshot()andRecent(); it never reachesSave(), so theDataDir: ""→/state.jsonpath is never written by the new test.+471/-1.http.Servertimeouts, request body limits, rate limiting and CORS scoping (#99/#100/#101) are untouched.Non-blocking observations
internal/middleware/middleware_test.go— no test exercises the realserver.SetupRoutes()stack. DoD item 2's/s/...and/metricscoverage, and the commit message's claims about recovered panics andTimeout504s, are correct but argued rather than asserted;TestDashboardRendersWithSecurityHeadersbuilds its own minimal chi router carrying onlySecurityHeaders. The issue's DoD item 4 does not require more, so this is not a defect. A future test serving/s/css/tailwind.min.cssand a panicking route through the assembled router would convert the reasoning above into a regression guard.README.md— the CSP is shown in a fenced block wrapped across three lines. The emitted header is a single line; a reader copy-pasting the block gets embedded newlines. Cosmetic only.make checkemits 25failed to save state error="writing temp state file: open /state.json.tmp: permission denied"lines. These originate ininternal/watchertests (internal/watcher/watcher_test.go:241usesstate.NewForTest(), and the watcher does callSave()), a file this PR does not touch. Log noise onmain, worth its own issue.Gate result
make checkrun locally one97a4e5in an isolated worktree: exit 0, lint0 issues., all packagesok,internal/middleware1.029s. Warm full-run wall time 1.408s; the cold run in the same session also exited 0. Comfortably inside the 20-second policy ceiling.fmt-checkclean, somake fmtis a no-op.CI on head
e97a4e5: success (check / check (push), 35s).Mergeable:
origin/main@9347a28is an ancestor of the head — fast-forward, no conflicts.Verdict
PASS — recommend
merge-ready.[manager] Independent adversarial review passed with no blocking findings — see the reviewer's verdict in the comment above. The reviewer did not author this change and re-ran the gate themselves rather than trusting the PR description.
Labeling
merge-readyand assigning to @sneak for merge (protectedmain).What I verified myself, independently of both the author and the reviewer
(closes #98)..golangci.ymlsha256 on the PR head is exactly021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb— unchanged, and absent from the diff.go.mod/go.sumuntouched; no dependency was added. The six headers are plainw.Header().Setcalls, which is the right call here — no "secure headers" library pulled in for something this small.TODO.mdupdated in the same commit as the work.Two things worth recording rather than quietly dropping
1. A process violation by the implementer. The implementing agent self-reported running
go test -run ...directly once, instead of going through amaketarget. It was read-only, it did not influence the result, and the authoritative verification came frommake check— but the rule exists because themaketargets carry project-specific flags and policy that raw invocations bypass, so it is logged here rather than waved through. No impact on the correctness of this change.2. The reviewer's suggested follow-up is already fixed and should not be filed. The review flagged 25
failed to save state ... open /state.json.tmp: permission deniedlines emitted byinternal/watchertests duringmake check, correctly identified as pre-existing and untouched by this PR, and suggested filing an issue to switchwatcher_test.go:241fromstate.NewForTest()to at.TempDir()-backed helper.That fix already exists in PR #97, which is
merge-readyand awaiting your merge — its head switches that exact line tostate.NewForTestWithDataDir(cfg.DataDir). Filing a new issue would duplicate work already sitting in the merge queue. The root cause underneath it —internal/state/state_test_helper.gobeing a non-test file whoseNewForTest()hardcodesDataDir: "", so the helpers ship in the production binary and default to writing to the filesystem root — is tracked separately in #111, explicitly sequenced to land after #97.Non-blocking, not fixed here
The reviewer noted the README renders the single-line CSP across three lines inside a fenced block, so copy-pasting it yields embedded newlines. Cosmetic, does not affect the emitted header, and not worth another review cycle on its own — it will get swept up in the README accuracy pass in #108.
Gate:
make checkgreen, lint0 issues.,origin/main@9347a28is an ancestor of the head so this fast-forwards cleanly.[manager] Lint result revalidated —
merge-readystands.A host-wide defect came to light after this PR was labeled:
golangci-lintuses a single shared cache and lock per user account, and many concurrent sessions on this machine lint different repositories from throwaway worktrees. Two confirmed failure modes — a run on a sibling repo returned 399 issues attributed to a worktree path belonging to another session, and runs can fail outright withError: parallel golangci-lint is running, which is a non-result that looks like a failure. Filed as #121.That meant the
0 issues.recorded for this PR could in principle have been computed from a different codebase, so I did not leave it standing on unverified evidence.Re-ran
make linton this PR's heade97a4e5in a fresh worktree with an isolated cache (GOLANGCI_LINT_CACHEpointed at a dedicated temporary directory):Validity checked against both void conditions: the output contains no
parallel golangci-lint is running, and mentions no file paths outside the worktree it ran in. The result is sound and the label is unaffected.The only other output was a pre-existing
gomodguarddeprecation warning, unrelated to this change and present onmain— tracked separately in #123 (corrected from an earlier version of this comment, which cited the wrong number; #122 is a pull request).feat: add security response headers middleware (closes #98)to WIP: feat: add security response headers middleware (closes #98)WIP: feat: add security response headers middleware (closes #98)to feat: add security response headers middleware (closes #98)Add SecurityHeaders() to internal/middleware and register it in the global middleware stack so every response - dashboard, embedded static assets, healthchecks, JSON API, and metrics - carries the six response headers required by REPO_POLICIES.md before tagging 1.0: Strict-Transport-Security: max-age=31536000; includeSubDomains Content-Security-Policy: default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self'; font-src 'none'; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none' X-Frame-Options: DENY X-Content-Type-Options: nosniff Referrer-Policy: no-referrer Permissions-Policy: unused browser features denied The dashboard template ships no JavaScript, no inline styles, no inline event handlers and no images, and its only subresource is the embedded stylesheet at /s/css/tailwind.min.css, so the policy needs neither unsafe-inline nor unsafe-eval. frame-ancestors 'none' is the primary anti-framing control with X-Frame-Options as the legacy fallback. HSTS is emitted unconditionally rather than gated on r.TLS, because the service runs behind a TLS-terminating proxy and the browser must still enforce HTTPS end to end. The headers are set before the request reaches the next handler, so they are present on error responses too, including recovered panics and request timeouts. Tests cover each header's exact value, the CSP's required and forbidden directives, presence on a 500 response, and a render of the real dashboard through the middleware confirming the page still references its stylesheet.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.