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) }