M462 security hardening and M464 loudness analysis (steps 1–3) #135

Merged
bvandeusen merged 18 commits from dev into main 2026-10-06 14:19:58 -04:00
6 changed files with 204 additions and 3 deletions
Showing only changes of commit d411693bb2 - Show all commits
+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()
}
+38
View File
@@ -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)
}
}
+1
View File
@@ -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)
+57
View File
@@ -0,0 +1,57 @@
package web
import (
"crypto/sha256"
"encoding/base64"
"regexp"
"strings"
)
// inlineScriptRe matches each <script> element and captures its attributes
// and body. index.html is our own build output, so a regexp is enough: there
// is no hostile markup to out-parse.
var inlineScriptRe = regexp.MustCompile(`(?is)<script\b([^>]*)>(.*?)</script>`)
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'",
}, "; ")
}
+60
View File
@@ -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 := "<html><head><script>" + theme + "</script>" +
`<script type="module" src="/_app/start.js"></script>` +
"<script>" + brand + "</script></head></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(`<script>window.__MINSTREL__ = { appName: "A" };</script>`))
b := contentSecurityPolicy([]byte(`<script>window.__MINSTREL__ = { appName: "B" };</script>`))
if a == b {
t.Error("different script bodies produced the same policy")
}
}
+9 -3
View File
@@ -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)
}