- 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
91 lines
3.5 KiB
Go
91 lines
3.5 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"
|
|
"testing"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/middleware"
|
|
)
|
|
|
|
func protect(t *testing.T, cfg middleware.CSRFConfig) http.Handler {
|
|
t.Helper()
|
|
mw, err := middleware.CSRF(cfg)
|
|
if err != nil {
|
|
t.Fatalf("building protection: %v", err)
|
|
}
|
|
return mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
}
|
|
|
|
func post(h http.Handler, path string, headers map[string]string) int {
|
|
req := httptest.NewRequest(http.MethodPost, path, nil)
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
return rec.Code
|
|
}
|
|
|
|
// A same-origin form post must pass. This is the case that matters most: the
|
|
// app sets Referrer-Policy: no-referrer, which used to null the Origin header
|
|
// and break native posts under gorilla/csrf. Sec-Fetch-Site is unaffected by
|
|
// referrer policy, so the old HTMX header workaround is no longer needed.
|
|
func TestSameOriginPostIsAllowed(t *testing.T) {
|
|
h := protect(t, middleware.CSRFConfig{})
|
|
if got := post(h, "/anything", map[string]string{"Sec-Fetch-Site": "same-origin"}); got != http.StatusOK {
|
|
t.Errorf("same-origin post got %d, want 200", got)
|
|
}
|
|
}
|
|
|
|
// The whole point of the migration.
|
|
func TestCrossSitePostIsRefused(t *testing.T) {
|
|
h := protect(t, middleware.CSRFConfig{})
|
|
if got := post(h, "/anything", map[string]string{"Sec-Fetch-Site": "cross-site"}); got != http.StatusForbidden {
|
|
t.Errorf("cross-site post got %d, want 403", got)
|
|
}
|
|
}
|
|
|
|
// A trusted origin is matched WITH its scheme. Under gorilla/csrf v1.7.3 the
|
|
// comparison was host-only, so http://... satisfied a policy written for
|
|
// https://... — that is GO-2025-3884 / CVE-2025-47909, and this pins the fix.
|
|
func TestTrustedOriginIsSchemeSensitive(t *testing.T) {
|
|
h := protect(t, middleware.CSRFConfig{TrustedOrigins: []string{"https://console.example.test"}})
|
|
|
|
if got := post(h, "/anything", map[string]string{
|
|
"Sec-Fetch-Site": "cross-site", "Origin": "https://console.example.test",
|
|
}); got != http.StatusOK {
|
|
t.Errorf("the trusted https origin got %d, want 200", got)
|
|
}
|
|
if got := post(h, "/anything", map[string]string{
|
|
"Sec-Fetch-Site": "cross-site", "Origin": "http://console.example.test",
|
|
}); got != http.StatusForbidden {
|
|
t.Errorf("plain http on a trusted https origin got %d, want 403 — this is the CVE", got)
|
|
}
|
|
}
|
|
|
|
// An origin without a scheme is a configuration error, not a silent no-op: a
|
|
// trusted origin that failed to register would leave a deployment rejecting
|
|
// its own posts.
|
|
func TestOriginWithoutSchemeIsRejectedAtConstruction(t *testing.T) {
|
|
if _, err := middleware.CSRF(middleware.CSRFConfig{TrustedOrigins: []string{"console.example.test"}}); err == nil {
|
|
t.Error("a scheme-less trusted origin was accepted; it must be a configuration error")
|
|
}
|
|
}
|
|
|
|
// Webhook paths authenticate by provider signature and must bypass entirely.
|
|
func TestBypassPatternExemptsAWebhookPath(t *testing.T) {
|
|
h := protect(t, middleware.CSRFConfig{BypassPatterns: []string{"/webhooks/stripe"}})
|
|
if got := post(h, "/webhooks/stripe", map[string]string{"Sec-Fetch-Site": "cross-site"}); got != http.StatusOK {
|
|
t.Errorf("exempt webhook path got %d, want 200", got)
|
|
}
|
|
if got := post(h, "/not-exempt", map[string]string{"Sec-Fetch-Site": "cross-site"}); got != http.StatusForbidden {
|
|
t.Errorf("the exemption leaked to another path: got %d, want 403", got)
|
|
}
|
|
}
|