From 8a87cfeb443c2ce29f9be92a8948639d49b6b773 Mon Sep 17 00:00:00 2001 From: kristianwind Date: Sun, 23 Aug 2026 23:15:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(security):=20HSTS=20og=20en=20lukket=20Per?= =?UTF-8?q?missions-Policy=20p=C3=A5=20hvert=20svar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To headere, appen ikke satte, og som begge følger af, hvad den allerede er. Sessionen bor i en __Host-cookie, der kun findes over HTTPS — hele modellen forudsætter TLS. Uden HSTS holder den forudsætning ikke mod en netværksangriber: den allerførste navigation kan strippes til http://, og cookien rækkes over i klartekst, før Secure-flaget nogensinde kommer i spil. HSTS lukker vinduet, og den er sat på nøjagtig samme betingelse som cookiens Secure-flag (secureCookies), så en dev-instans på ren HTTP aldrig bliver bedt om et certifikat, den ikke har. To år, underdomæner med; ingen preload — at blive hard-kodet ind i browsere er en beslutning for domænets ejer, ikke for en selv-hostet app. verdande beder aldrig om kamera, mikrofon eller placering, så den sikreste Permissions-Policy er at slå dem alle fra: et indsat element kan ikke bede om det, siden selv har afvist. Navngivet felt for felt frem for med et jokertegn, fordi jokertegns-formen ikke forstås ens af alle browsere, og en regel, en browser ignorerer, er ingen regel. Prøve på begge: HSTS følger HTTPS og udebliver over HTTP, og Permissions-Policy lukker for kameraet uanset skema. Co-Authored-By: Claude Opus 5 --- internal/httpapi/csp_header_test.go | 31 +++++++++++++++++++++++++++++ internal/httpapi/server.go | 20 +++++++++++++++++++ 2 files changed, 51 insertions(+) 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) }) }