From d411693bb27fb3757d4d655528a734699ced0694 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 09:29:26 -0400 Subject: [PATCH] feat(server): security headers and a hash-based CSP for the web app (M462 #4980) 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 --- internal/server/hygiene.go | 39 +++++++++++++++++++++ internal/server/hygiene_test.go | 38 +++++++++++++++++++++ internal/server/server.go | 1 + web/csp.go | 57 +++++++++++++++++++++++++++++++ web/csp_test.go | 60 +++++++++++++++++++++++++++++++++ web/embed.go | 12 +++++-- 6 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 web/csp.go create mode 100644 web/csp_test.go diff --git a/internal/server/hygiene.go b/internal/server/hygiene.go index 77901ae2..649f392f 100644 --- a/internal/server/hygiene.go +++ b/internal/server/hygiene.go @@ -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() +} diff --git a/internal/server/hygiene_test.go b/internal/server/hygiene_test.go index 3e432b96..ae9a1c4f 100644 --- a/internal/server/hygiene_test.go +++ b/internal/server/hygiene_test.go @@ -88,3 +88,41 @@ func TestRequireJSONForCookieWrites(t *testing.T) { }) } } + +func TestSecurityHeaders(t *testing.T) { + serve := func(hops int, proto string) http.Header { + h := securityHeaders(func() int { return hops })(okHandler()) + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) + if proto != "" { + req.Header.Set("X-Forwarded-Proto", proto) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + return w.Header() + } + + base := serve(0, "") + for key, want := range map[string]string{ + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "strict-origin-when-cross-origin", + "X-Frame-Options": "DENY", + } { + if got := base.Get(key); got != want { + t.Errorf("%s = %q, want %q", key, got, want) + } + } + if !strings.Contains(base.Get("Permissions-Policy"), "microphone=()") { + t.Errorf("Permissions-Policy = %q", base.Get("Permissions-Policy")) + } + + // Rule 94's three cases for HSTS. + if got := serve(0, "https").Get("Strict-Transport-Security"); got != "" { + t.Errorf("hops 0 + forwarded https: HSTS = %q, want none", got) + } + if got := serve(1, "https").Get("Strict-Transport-Security"); got == "" { + t.Error("hops 1 + forwarded https: want HSTS") + } + if got := serve(1, "").Get("Strict-Transport-Security"); got != "" { + t.Errorf("plain request: HSTS = %q, want none", got) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 510278ed..efae1029 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -139,6 +139,7 @@ func (s *Server) Router() http.Handler { r.Use(middleware.RequestID) r.Use(requestLog(s.Logger, netSettings.Hops)) r.Use(middleware.Recoverer) + r.Use(securityHeaders(netSettings.Hops)) r.Use(limitRequestBody) r.Use(requireJSONForCookieWrites) diff --git a/web/csp.go b/web/csp.go new file mode 100644 index 00000000..b255998c --- /dev/null +++ b/web/csp.go @@ -0,0 +1,57 @@ +package web + +import ( + "crypto/sha256" + "encoding/base64" + "regexp" + "strings" +) + +// inlineScriptRe matches each `) + +var srcAttrRe = regexp.MustCompile(`(?i)\bsrc\s*=`) + +// contentSecurityPolicy builds the policy served with index.html. +// +// Scripts are allowed from our own origin plus the exact inline scripts the +// page carries: the theme bootstrap in app.html, the branding global the Vite +// plugin injects, and SvelteKit's start-up block. Their hashes are taken from +// the page AFTER the branding template has run, so they match the bytes the +// browser actually receives, whatever the operator's app name. Any script +// that differs, including one injected through an XSS, is refused. +// +// The rest: +// - style-src allows inline styles: Svelte transitions and style: +// directives write them, and inline style is not a script vector. +// - img-src admits https:/http: because Lidarr suggestion art is a remote +// poster URL. Images cannot run code. +// - media-src/connect-src stay on our own origin: streams, the API and the +// SSE stream are all same-origin. blob: covers Web Audio and object URLs. +func contentSecurityPolicy(indexHTML []byte) string { + scriptSrc := []string{"'self'"} + for _, m := range inlineScriptRe.FindAllSubmatch(indexHTML, -1) { + if srcAttrRe.Match(m[1]) { + continue + } + sum := sha256.Sum256(m[2]) + scriptSrc = append(scriptSrc, "'sha256-"+base64.StdEncoding.EncodeToString(sum[:])+"'") + } + return strings.Join([]string{ + "default-src 'self'", + "base-uri 'self'", + "object-src 'none'", + "frame-ancestors 'none'", + "form-action 'self'", + "script-src " + strings.Join(scriptSrc, " "), + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https: http:", + "font-src 'self' data:", + "media-src 'self' blob:", + "connect-src 'self'", + "worker-src 'self' blob:", + "manifest-src 'self'", + }, "; ") +} diff --git a/web/csp_test.go b/web/csp_test.go new file mode 100644 index 00000000..b603597a --- /dev/null +++ b/web/csp_test.go @@ -0,0 +1,60 @@ +package web + +import ( + "crypto/sha256" + "encoding/base64" + "strings" + "testing" +) + +func hashOf(body string) string { + sum := sha256.Sum256([]byte(body)) + return "'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'" +} + +func TestContentSecurityPolicy_HashesInlineScriptsOnly(t *testing.T) { + theme := "(function () { document.documentElement.dataset.theme = 'dark'; })();" + brand := `window.__MINSTREL__ = { appName: "Minstrel" };` + html := "" + + `` + + "" + + csp := contentSecurityPolicy([]byte(html)) + + var scriptSrc string + for _, d := range strings.Split(csp, "; ") { + if strings.HasPrefix(d, "script-src ") { + scriptSrc = d + } + } + if scriptSrc == "" { + t.Fatalf("no script-src in %q", csp) + } + for _, want := range []string{"'self'", hashOf(theme), hashOf(brand)} { + if !strings.Contains(scriptSrc, want) { + t.Errorf("script-src %q missing %s", scriptSrc, want) + } + } + if strings.Count(scriptSrc, "'sha256-") != 2 { + t.Errorf("script-src %q: want exactly the two inline scripts hashed, not the src= one", scriptSrc) + } + if strings.Contains(scriptSrc, "unsafe-inline") || strings.Contains(scriptSrc, "unsafe-eval") { + t.Errorf("script-src must not fall back to unsafe-*: %q", scriptSrc) + } + for _, want := range []string{"frame-ancestors 'none'", "object-src 'none'", "connect-src 'self'"} { + if !strings.Contains(csp, want) { + t.Errorf("csp missing %q", want) + } + } +} + +// The hash must be of the page as served, after the branding template has +// substituted the operator's app name, or a renamed instance would refuse +// its own bootstrap script. +func TestContentSecurityPolicy_TracksTemplatedContent(t *testing.T) { + a := contentSecurityPolicy([]byte(``)) + b := contentSecurityPolicy([]byte(``)) + if a == b { + t.Error("different script bodies produced the same policy") + } +} diff --git a/web/embed.go b/web/embed.go index 2c63b3c9..690c41c7 100644 --- a/web/embed.go +++ b/web/embed.go @@ -47,12 +47,14 @@ func Handler(branding config.BrandingConfig) http.Handler { panic("web: branding template failed: " + err.Error()) } + csp := contentSecurityPolicy(index) + fileServer := http.FileServer(http.FS(sub)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { clean := path.Clean(r.URL.Path) if clean == "/" || clean == "." { - serveIndex(w, index) + serveIndex(w, index, csp) return } name := strings.TrimPrefix(clean, "/") @@ -60,12 +62,16 @@ func Handler(branding config.BrandingConfig) http.Handler { fileServer.ServeHTTP(w, r) return } - serveIndex(w, index) + serveIndex(w, index, csp) }) } -func serveIndex(w http.ResponseWriter, index []byte) { +func serveIndex(w http.ResponseWriter, index []byte, csp string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") + // The policy only means anything on the document; JSON, audio and image + // responses can't run script, so it rides here rather than on every + // response. + w.Header().Set("Content-Security-Policy", csp) w.Header().Set("Cache-Control", "no-cache") _, _ = w.Write(index) }