Files
member-console/internal/middleware/tests/security_headers_test.go
T
cgalo5758 0b28a9dc29 Remediate security audit findings
- Replace gorilla/csrf with net/http CrossOriginProtection
- Require valkey-password and add TLS options for session store
- End session at /logout and revoke refresh tokens
- Re-derive identity and roles from provider every five minutes
- Process each Stripe webhook event in its own Temporal workflow
- Give each outbox entry its own workflow with Temporal retries
- Guard against stale Stripe events with provider timestamps
- Derive transport security from base-url scheme
2026-09-09 13:25:43 -05:00

162 lines
6.0 KiB
Go

// 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)
}
})
}