M462 security hardening and M464 loudness analysis (steps 1–3) #135
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'",
|
||||
}, "; ")
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user