feat(server): security headers and a hash-based CSP for the web app (M462 #4980)
test-web / test (push) Successful in 55s
test-go / test (push) Successful in 1m14s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 2m50s
test-go / integration (push) Canceled after 2m50s

There were no security headers at all. Now:
- every response: nosniff, Referrer-Policy strict-origin-when-cross-origin,
  Permissions-Policy (no camera/mic/geolocation), X-Frame-Options DENY;
  each set only if the handler hasn't.
- HSTS only when the trusted proxy reports HTTPS (rule 94); never a redirect.
- index.html carries a Content-Security-Policy whose script-src is 'self'
  plus the sha256 of each inline script in the page as served, computed
  after the branding template runs. No 'unsafe-inline' or 'unsafe-eval'
  for scripts. img-src admits remote https/http because Lidarr suggestion
  art is a remote poster URL.

Hashing in Go rather than via SvelteKit's kit.csp covers the inline scripts
SvelteKit doesn't know about (app.html's theme bootstrap and the branding
global injected at build) and stays correct whatever the app name is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 09:29:26 -04:00
co-authored by Claude Opus 5.5
parent 24b767c23b
commit d411693bb2
6 changed files with 204 additions and 3 deletions
+39
View File
@@ -113,3 +113,42 @@ func requireJSONForCookieWrites(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
// securityHeaders sets the response headers every response should carry.
// Each is set only when the handler hasn't, so a route with a reason to
// differ keeps its own value. The document's Content-Security-Policy is set
// by the SPA handler, which knows the inline-script hashes.
//
// HSTS goes out only when the request reached us over HTTPS as the trusted
// proxy reports it (rule 94): sending it over plain HTTP is ignored by
// browsers at best, and the app never forces HTTPS.
func securityHeaders(hops func() int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
setDefault(h, "X-Content-Type-Options", "nosniff")
setDefault(h, "Referrer-Policy", "strict-origin-when-cross-origin")
setDefault(h, "Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()")
// frame-ancestors in the document's CSP covers modern browsers;
// this covers the rest, and every non-HTML response.
setDefault(h, "X-Frame-Options", "DENY")
if auth.IsHTTPS(r, hopsOrZero(hops)) {
setDefault(h, "Strict-Transport-Security", "max-age=31536000")
}
next.ServeHTTP(w, r)
})
}
}
func setDefault(h http.Header, key, value string) {
if h.Get(key) == "" {
h.Set(key, value)
}
}
func hopsOrZero(hops func() int) int {
if hops == nil {
return 0
}
return hops()
}