- 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
162 lines
6.0 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|