Add security response headers middleware (HSTS, CSP, X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy) #98
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
REPO_POLICIES.mdrequires that HTTP/web services be "hardened for production internet exposure before tagging 1.0", and lists six security response headers as mandatory on every response. dnswatcher currently sets none of them.Current state (audited against
origin/main, commit9347a28)internal/middleware/middleware.godefines onlyLogging(),CORS(), andMetricsAuth().internal/server/routes.go:22-26registerschimw.Recoverer,chimw.RequestID,Logging(),CORS(),chimw.Timeout— and nothing else. A repo-wide grep forStrict-Transport|Content-Security-Policy|X-Frame-Options|X-Content-Type-Options|Referrer-Policy|Permissions-Policyacross all*.goreturns zero matches.This affects every route: the HTML dashboard at
/, the embedded static assets at/s/..., the JSON API at/api/v1/status, the healthchecks, and/metrics.Why a strict CSP is achievable with zero template changes
internal/handlers/templates/dashboard.html(370 lines) has been checked for<style,<script,style=,onclick=,onload=, andjavascript:— zero matches for all of them. The page uses Tailwind utility classes only, loads exactly one external stylesheet (/s/css/tailwind.min.css, served from the embeddedstatic/FS), and has no<script>tags at all — the 30-second auto-refresh is a<meta http-equiv="refresh">, not JavaScript.So the strictest possible policy is reachable today. There is no
unsafe-inline/unsafe-evaltradeoff to negotiate.Definition of done
SecurityHeaders()) on*Middlewareininternal/middleware/middleware.gosets all six headers on every response:Strict-Transport-Security: max-age=31536000; includeSubDomains(max-age is at least one year, andincludeSubDomainsis present).Content-Security-Policywith a restrictive policy that contains neitherunsafe-inlinenorunsafe-eval.default-src 'self'as the baseline,script-src 'none'(the app ships no JavaScript), and whateverstyle-src/img-srcthe dashboard genuinely needs.X-Frame-Options: DENY, andframe-ancestors 'none'in the CSP as the primary control (policy: "Prefer theframe-ancestorsCSP directive as the primary control").X-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originor stricter.Permissions-Policydenying the browser features this app does not use (camera, microphone, geolocation, at minimum).internal/server/routes.goso it applies to every route including/s/...static assets and/metrics.r.TLS != nil.internal/middlewareassert every header's exact value on a response, plus at least one test asserting the dashboard route still renders successfully with the CSP applied./s/css/tailwind.min.cssunderstyle-src 'self'.make checkis green, andTODO.mdis updated in the same commit as the work.The finishing commit's title must end with
(closes #N)referencing this issue.Out of scope
http.Servertimeouts, rate limiting, and CORS scoping are tracked separately — do not fold them into this PR.Implementation plan (branch
fix/98-security-headers, fromorigin/main@9347a28):1.
internal/middleware/middleware.go— newSecurityHeaders()method on*MiddlewarePackage-level string constants plus a middleware that sets six headers on every response before calling
next.ServeHTTP, so they are emitted regardless of status code (includingchimw.Recoverer500s andchimw.Timeout504s):Strict-Transport-Security: max-age=31536000; includeSubDomains— emitted unconditionally, never gated onr.TLS != nil, per the policy note about TLS-terminating proxies.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: DENYX-Content-Type-Options: nosniffReferrer-Policy: no-referrerPermissions-Policydenying camera, microphone, geolocation and the rest of the feature set the app does not use.CSP reasoning, verified against
internal/handlers/templates/dashboard.htmlandstatic/: the template has zero<script>tags, zero inlinestyle=/ event handlers, and no<img>; the only subresource is<link rel="stylesheet" href="/s/css/tailwind.min.css">, whichstyle-src 'self'permits.static/css/tailwind.min.csscontains nourl()and no@font-face, sofont-src 'none'is safe. No JavaScript meansscript-src 'none'andconnect-src 'none'cost nothing.img-src 'self'is kept rather than'none'so a future/favicon.icois not blocked. Neitherunsafe-inlinenorunsafe-evalappears.frame-ancestors 'none'is the primary anti-framing control, withX-Frame-Options: DENYas the legacy fallback.Referrer-Policy: no-referreris chosen as strictly stricter thanstrict-origin-when-cross-origin— the dashboard URL can contain internal hostnames and the app has no cross-origin navigation needs.2.
internal/server/routes.go— registers.router.Use(s.mw.SecurityHeaders())in the global middleware stack (immediately afterchimw.RequestID, beforeCORS), so it covers/,/s/..., the healthchecks,/api/v1/status, and the/metricsgroup.3. Tests — new
internal/middleware/middleware_test.go(externalpackage middleware_test)unsafe-inlinenorunsafe-evaland does containframe-ancestors 'none'.handlers.HandleDashboard()wired through a chi router withSecurityHeaders(), asserting HTTP 200, that the body still references/s/css/tailwind.min.css, and that the CSP header is set on the rendered page. No DNS is involved anywhere in this issue.4. Docs/bookkeeping — a short "Security headers" subsection in the README under the HTTP API area documenting the six headers and the CSP rationale;
TODO.mdupdated in the same commit;make fmtrun andmake checkgreen before the PR..golangci.ymland the golangci-lint pin are untouched.Out of scope and not touched:
http.Servertimeouts, rate limiting, CORS scoping, request body limits.