// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package tests import ( "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/spf13/viper" "git.coopcloud.tech/wiki-cafe/member-console/internal/middleware" ) func servedCSP(t *testing.T) string { t.Helper() h := middleware.SecureHeaders()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) csp := rec.Result().Header.Get("Content-Security-Policy") if csp == "" { t.Fatal("no Content-Security-Policy header was set") } return csp } // The policy must authorize only origins the application actually loads from. // Front-end assets are vendored into internal/embeds/static/ and served from // /static/, so no content delivery network belongs in script-src. A dead // unpkg.com entry lived here until the 2026-09 security audit. func TestCSPAllowsNoThirdPartyScriptOrigin(t *testing.T) { csp := servedCSP(t) var scriptSrc string for _, d := range strings.Split(csp, ";") { if d = strings.TrimSpace(d); strings.HasPrefix(d, "script-src") { scriptSrc = d } } if scriptSrc == "" { t.Fatalf("policy declares no script-src: %q", csp) } if scriptSrc != "script-src 'self'" { t.Errorf("script-src must be exactly \"script-src 'self'\", got %q", scriptSrc) } for _, banned := range []string{"unpkg.com", "cdn.", "https://", "http://", "*"} { if strings.Contains(scriptSrc, banned) { t.Errorf("script-src names a third-party or wildcard source %q: %q", banned, scriptSrc) } } } // Defence in depth around the same header set. func TestSecurityHeadersPinTheObviousProtections(t *testing.T) { h := middleware.SecureHeaders()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) res := rec.Result() for header, want := range map[string]string{ "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Referrer-Policy": "no-referrer", } { if got := res.Header.Get(header); got != want { t.Errorf("%s = %q, want %q", header, got, want) } } if !strings.Contains(servedCSP(t), "frame-ancestors 'none'") { t.Error("policy must set frame-ancestors 'none'") } } // upgrade-insecure-requests follows base-url's scheme, not the env label // (2026-09 security audit, finding 5): served over https it is on whatever // the deployment calls itself; over plain http it would upgrade every // subresource to a scheme nothing listens on, so it is off. func TestCSPUpgradeDirectiveFollowsTheBaseURLScheme(t *testing.T) { t.Cleanup(viper.Reset) for _, tc := range []struct { baseURL string env string want bool }{ {"https://console.example.coop", "staging", true}, {"https://console.example.coop", "development", true}, {"http://member-console.localhost:9431", "production", false}, } { viper.Set("base-url", tc.baseURL) viper.Set("env", tc.env) got := strings.Contains(servedCSP(t), "upgrade-insecure-requests") if got != tc.want { t.Errorf("base-url %q env %q: upgrade-insecure-requests present = %v, want %v", tc.baseURL, tc.env, got, tc.want) } } } // Strict-Transport-Security is sent only where the console is served over // https: RFC 6797 section 7.2 forbids it over non-secure transport. The value // is a year with includeSubDomains; the one-hour value it replaced lapsed // before most return visits, which re-opened the first plain-HTTP request // HSTS exists to prevent. func TestHSTSFollowsTheBaseURLSchemeWithAYearLongPolicy(t *testing.T) { t.Cleanup(viper.Reset) serve := func(baseURL string) string { viper.Set("base-url", baseURL) h := middleware.SecureHeaders()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) return rec.Result().Header.Get("Strict-Transport-Security") } if got, want := serve("https://console.example.coop"), "max-age=31536000; includeSubDomains"; got != want { t.Errorf("over https: Strict-Transport-Security = %q, want %q", got, want) } if got := serve("http://member-console.localhost:9431"); got != "" { t.Errorf("over http: Strict-Transport-Security = %q, want none (RFC 6797 section 7.2)", got) } } // The size limit and the timeout answer for the handler (a 413, a 503), and // the timeout handler discards whatever an inner middleware set. So the // security headers must be applied outside both, which is the order the // server's stack uses (2026-09 audit candidate "413 responses bypass the // security-header middleware"). This pins that order's effect. func TestSecurityHeadersReachRefusalsWrittenByOuterMiddleware(t *testing.T) { refused := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("the handler must not run") }) t.Run("413 from the body limit", func(t *testing.T) { h := middleware.SecureHeaders()(middleware.MaxBodySize(4)(refused)) req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("too large")) req.ContentLength = 9 rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusRequestEntityTooLarge { t.Fatalf("status = %d, want 413", rec.Code) } if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { t.Errorf("the 413 carries no security headers (X-Content-Type-Options = %q)", got) } }) t.Run("503 from the timeout", func(t *testing.T) { slow := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }) h := middleware.SecureHeaders()(middleware.Timeout(10 * time.Millisecond)(slow)) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d, want 503", rec.Code) } if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { t.Errorf("the 503 carries no security headers (X-Content-Type-Options = %q)", got) } }) }