The rate limiter and the audit log key on the caller's address, and that address was read with chi's middleware.RealIP — which believes X-Forwarded-For from anyone. So a caller could set it to a new value on every request and get a fresh password-guess bucket each time, and could write any address they liked into the log the panel's watchers read. The one control meant for when a password is already gone was keyed on a number the attacker got to pick. Now the header is believed only when the machine that *connected* is a trusted proxy — by default anything on a private network, which is the shape of a reverse proxy or tunnel sharing the host or the docker bridge. A connection straight off the internet is trusted for nothing and is keyed on the address it actually came from. VERDANDE_TRUSTED_PROXIES overrides the set (CIDRs, or "none" to trust no proxy at all); VERDANDE_REAL_IP_HEADER names the header. Verified in the current deployment before the change: behind the Cloudflare Tunnel a spoofed X-Real-IP already got no fresh bucket, because cloudflared overwrites it. This makes that safety a property of the code, not of the deployment — expose the instance directly, or put a header-passing proxy in front, and it would have been a live hole. Also COOP: Cross-Origin-Opener-Policy: same-origin on every response, isolating the browsing context. verdande scripts no other window, so there is nothing to lose and a class of cross-window attacks to close. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
102 lines
3.9 KiB
Go
102 lines
3.9 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)
|
|
}
|
|
// COOP isolerer browserkonteksten og står på hvert svar.
|
|
if got := resp.Header.Get("Cross-Origin-Opener-Policy"); got != "same-origin" {
|
|
t.Errorf("Cross-Origin-Opener-Policy er ikke same-origin: %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)
|
|
}
|
|
}
|
|
}
|