diff --git a/internal/httpapi/csp_header_test.go b/internal/httpapi/csp_header_test.go index d0b37fe..3275dea 100644 --- a/internal/httpapi/csp_header_test.go +++ b/internal/httpapi/csp_header_test.go @@ -3,6 +3,8 @@ package httpapi import ( "strings" "testing" + + "github.com/kristianwind/verdande/internal/config" ) // Politikken skal stå i svaret, og den skal stadig være stram. @@ -50,6 +52,35 @@ func TestEveryResponseCarriesAStrictCSP(t *testing.T) { } } +// HSTS følger med, når instansen kører over HTTPS — og kun der. +// +// Sessionen bor i en __Host-cookie, der kun findes over TLS, så hele modellen +// forudsætter allerede HTTPS. HSTS er det, der holder den forudsætning mod en +// netværksangriber; men bedt om over ren HTTP ville den bede en browser om at +// kræve et certifikat, en dev-instans ikke har. Begge halvdele skal stå prøven. +func TestHSTSTracksHTTPS(t *testing.T) { + overHTTP := newTestServer(t) // BaseURL er http://localhost + resp, _ := overHTTP.do(t, "GET", "/api/v1/ping", nil) + if got := resp.Header.Get("Strict-Transport-Security"); got != "" { + t.Errorf("HSTS sat over ren HTTP: %q", got) + } + + overHTTPS := newTestServerWith(t, func(c *config.Config) { + c.BaseURL = "https://todo.example.dk" + }) + resp, _ = overHTTPS.do(t, "GET", "/api/v1/ping", nil) + if got := resp.Header.Get("Strict-Transport-Security"); got == "" { + t.Error("ingen HSTS over HTTPS") + } else if !strings.Contains(got, "includeSubDomains") { + t.Errorf("HSTS uden includeSubDomains: %q", got) + } + // Permissions-Policy står på hvert svar, uanset skema: den lukker for kamera, + // mikrofon og placering, som appen aldrig beder om. + if got := resp.Header.Get("Permissions-Policy"); !strings.Contains(got, "camera=()") { + t.Errorf("Permissions-Policy lukker ikke for kameraet: %q", got) + } +} + // Et bilag må ikke kunne blive læst som et dokument. func TestAttachmentsAreNotServedAsDocuments(t *testing.T) { for _, mime := range []string{"image/svg+xml", "text/html", "application/xhtml+xml", "text/xml"} { diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 8418b67..9ee7e37 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -665,6 +665,26 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler { h.Set("X-Frame-Options", "DENY") h.Set("Referrer-Policy", "same-origin") h.Set("Content-Security-Policy", s.csp) + // The session lives in a __Host- cookie that only exists over HTTPS, so the + // whole security model already assumes TLS. HSTS is what makes that + // assumption hold against a network attacker: without it, the very first + // navigation can be stripped to http:// and the cookie handed over in the + // clear. Gated on the same condition as the cookie's Secure flag, so a + // plain-HTTP dev instance is never told to demand a certificate it cannot + // present. Two years, subdomains included; no preload directive, because + // asking to be hard-coded into browsers is a decision the operator makes + // for their domain, not one a self-hosted app makes for them. + if s.secureCookies() { + h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") + } + // verdande uses none of the powerful browser features, so the safest policy + // is to turn them all off: an injected element cannot ask for the camera, + // the microphone or the location if the page itself has already declined + // them. Named explicitly rather than with a wildcard because the wildcard + // form of this header is not universally understood, and a directive a + // browser ignores is not a control. + h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), "+ + "payment=(), usb=(), interest-cohort=()") next.ServeHTTP(w, r) }) }