Files
verdande/internal/httpapi/csp_header_test.go
T
kristianwindandClaude Opus 5 8a87cfeb44 feat(security): HSTS og en lukket Permissions-Policy på hvert svar
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 <noreply@anthropic.com>
2026-08-23 23:15:33 +02:00

98 lines
3.7 KiB
Go

package httpapi
import (
"strings"
"testing"
"github.com/kristianwind/verdande/internal/config"
)
// Politikken skal stå i svaret, og den skal stadig være stram.
//
// Den er dét, der gjorde en XSS i noteeditoren til indsat HTML frem for kørt
// script, dengang en `alt` slap uden om sin undslupning — og der var ingen prøve
// på, at headeren overhovedet blev sendt. En sikkerhedsforanstaltning, ingen
// prøve rører, er en, der kan forsvinde i en oprydning uden at nogen opdager det.
func TestEveryResponseCarriesAStrictCSP(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, _ := ts.do(t, "GET", "/api/v1/auth/me", nil)
policy := resp.Header.Get("Content-Security-Policy")
if policy == "" {
t.Fatal("der er ingen Content-Security-Policy på svaret")
}
// Målt på script-src alene, og ikke på hele politikken.
//
// `style-src 'unsafe-inline'` står der med vilje og er forklaret i csp.go:
// Svelte skriver stilarter ind i dokumentet, og en indsat stil kan gøre en
// side grim, ikke køre kode. Det er script-src, der afgør, om indsat HTML
// bliver til et kørende script, og det er derfor den, prøven ser på.
scriptSrc := ""
for _, part := range strings.Split(policy, ";") {
if strings.HasPrefix(strings.TrimSpace(part), "script-src") {
scriptSrc = part
}
}
if scriptSrc == "" {
t.Fatal("der er ingen script-src i politikken")
}
for _, forbidden := range []string{"'unsafe-inline'", "'unsafe-eval'", "'unsafe-hashes'", "'strict-dynamic'"} {
if strings.Contains(scriptSrc, forbidden) {
t.Errorf("script-src indeholder %s: %q", forbidden, strings.TrimSpace(scriptSrc))
}
}
// Og de fire, der skal være der.
for _, want := range []string{"script-src", "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'"} {
if !strings.Contains(policy, want) {
t.Errorf("politikken mangler %q", want)
}
}
}
// 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"} {
if inlineImage[mime] {
t.Errorf("%s står på listen over det, der vises inline", mime)
}
}
// Og listen skal faktisk kunne noget, ellers virker billeder i noter ikke.
for _, mime := range []string{"image/png", "image/jpeg"} {
if !inlineImage[mime] {
t.Errorf("%s mangler; billeder i noter kan ikke vises", mime)
}
}
}