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)
|
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(middleware.RequestID)
|
||||||
r.Use(requestLog(s.Logger, netSettings.Hops))
|
r.Use(requestLog(s.Logger, netSettings.Hops))
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
|
r.Use(securityHeaders(netSettings.Hops))
|
||||||
r.Use(limitRequestBody)
|
r.Use(limitRequestBody)
|
||||||
r.Use(requireJSONForCookieWrites)
|
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())
|
panic("web: branding template failed: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
csp := contentSecurityPolicy(index)
|
||||||
|
|
||||||
fileServer := http.FileServer(http.FS(sub))
|
fileServer := http.FileServer(http.FS(sub))
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
clean := path.Clean(r.URL.Path)
|
clean := path.Clean(r.URL.Path)
|
||||||
if clean == "/" || clean == "." {
|
if clean == "/" || clean == "." {
|
||||||
serveIndex(w, index)
|
serveIndex(w, index, csp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
name := strings.TrimPrefix(clean, "/")
|
name := strings.TrimPrefix(clean, "/")
|
||||||
@@ -60,12 +62,16 @@ func Handler(branding config.BrandingConfig) http.Handler {
|
|||||||
fileServer.ServeHTTP(w, r)
|
fileServer.ServeHTTP(w, r)
|
||||||
return
|
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")
|
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.Header().Set("Cache-Control", "no-cache")
|
||||||
_, _ = w.Write(index)
|
_, _ = w.Write(index)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user