Implements the remediation pass described in planning/19-security-audit-2026-04-30.md. All Critical findings and the Wave 1-4 High findings are closed; PoC tests added; full backend test suite green; helm chart lints clean. Wave 1 - Auth & identity - C1 OAuth state nonce: PutOAuthState / ConsumeOAuthState (valkey, GETDEL single-use, 15min TTL); Callback rejects missing/forged/cross- provider state before token exchange. - C2 OAuth identity linking: refuse silent linking to existing user unless info.EmailVerified is true. fetchGitHubUser now consults the /user/emails endpoint for the verified flag (no more hardcoded true); fetchFacebookUser sets EmailVerified=false (FB exposes no per-email verification flag). - H1 Magic-link verify: replaced Get + MarkUsed with a single atomic UPDATE...RETURNING (ConsumeMagicLink) - TOCTOU-free. - H2 TOTP code replay: MarkTOTPCodeConsumed (valkey SET NX, 120s TTL) prevents replay of a successfully validated code; fails closed on transient store errors. - H3 Backup-code orphan: DisableTOTP now also wipes totp_backup_codes. Wave 2 - Middleware & network - C3 CORS/CSRF regex anchoring: NewCORSConfig wraps each pattern with \A...\z so substring spoofing of origins is impossible. - H4 ClientIP: server reads APP_TRUSTED_PROXIES; gin SetTrustedProxies is called explicitly (empty default = no proxy trust). - H11 Body limit + DisallowUnknownFields: BodyLimitBytes middleware (1 MiB default) wraps every request; validate.BindJSON now uses a json.Decoder with DisallowUnknownFields and rejects trailing tokens; 413 envelope on body-limit overflow. - H16 NetworkPolicy: backend.networkPolicy.enabled defaults to true; new web-networkpolicy.yaml restricts web pod ingress to nginx-gateway and egress to backend service + DNS + 443. Wave 3 - Encryption at rest - C4 TOTP secrets: CreateTOTPSecret writes encrypted secret_v2; GetTOTPSecret prefers v2 with legacy fallback. - C5 OAuth tokens: migration 000033 adds *_v2 columns; CreateOAuthAccount and UpdateOAuthTokens write encrypted; GetOAuthAccount reads v2 with legacy fallback. - M1 Domain separation: crypto.DeriveKeyFor(secret, purpose) replaces single-purpose DeriveKey; settings, totp, oauth each use a distinct HKDF-derived subkey. DeriveKey kept as back-compat alias for settings. Wave 4 - Input & AI safety - C6 SSRF: new pkg/safehttp refuses to dial RFC1918, loopback, link- local, ULA, multicast, unspecified, or cloud-metadata IPs; scheme allowlist (http/https). Wired into pkg/scrape, discovery LinkChecker, and imageURLReachable. NewForTesting opt-in for httptest. - H13 PromptGuard German + Unicode: NFKC + Cf-class strip pre-pass closes zero-width and full-width-homoglyph bypasses; new German rules for ignoriere/missachte/vergiss/role-escalation/prompt-exfil/verbatim; Gemma-style and pipe-delimited chat-template tokens covered; source-fence rule prevents '=== Quelle:' splice in scraped text. - H14 BudgetGate: new ai.BudgetGate interface; UsageRepo.CheckBudget reads today's SUM(estimated_cost_usd) (10s cache) and refuses calls when AI_DAILY_CAP_USD is exceeded; GeminiProvider.Chat checks the gate before contacting Gemini. OAuth routes remain disabled in server/routes.go, so C1/C2 are not actively reachable today; fixes ensure correctness when re-enabled.
110 lines
3.7 KiB
Go
110 lines
3.7 KiB
Go
package middleware_test
|
|
|
|
import (
|
|
"bytes"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"marktvogt.de/backend/internal/middleware"
|
|
)
|
|
|
|
const apexOrigin = "https://marktvogt.de"
|
|
|
|
// PoC for audit C3: a CORS pattern intended for the apex domain must NOT match
|
|
// a maliciously-suffixed origin. Pre-fix, regexp.Compile("marktvogt\\.de") ran
|
|
// MatchString as a substring, so https://marktvogt.de.evil.example was accepted.
|
|
// Post-fix, NewCORSConfig wraps every pattern with \A…\z so origin spoofing is
|
|
// impossible regardless of how the operator wrote the pattern.
|
|
func TestCORS_C3_AnchorsPreventSubstringSpoofing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Operator supplies a pattern that includes scheme + host. Without the
|
|
// audit-fix wrap, regexp.MatchString would accept any origin containing
|
|
// "https://marktvogt.de" as a substring (e.g. evil.example/?x=https://marktvogt.de).
|
|
cfg, err := middleware.NewCORSConfig(nil, []string{`https://marktvogt\.de`})
|
|
if err != nil {
|
|
t.Fatalf("NewCORSConfig: %v", err)
|
|
}
|
|
|
|
r := gin.New()
|
|
r.Use(middleware.CORS(cfg))
|
|
r.GET("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
|
|
|
|
bad := []string{
|
|
"https://marktvogt.de.evil.example",
|
|
"https://marktvogt.de.attacker",
|
|
"https://marktvogt.de@evil.example",
|
|
"https://marktvogt.de/something\nhttps://evil.example",
|
|
}
|
|
for _, origin := range bad {
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
req.Header.Set("Origin", origin)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
|
t.Errorf("origin %q: must not match anchored pattern, but ACAO=%q", origin, got)
|
|
}
|
|
}
|
|
|
|
// Exact origin still matches.
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
req.Header.Set("Origin", apexOrigin)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if got := w.Header().Get("Access-Control-Allow-Origin"); got != apexOrigin {
|
|
t.Errorf("legit origin still must match: ACAO=%q", got)
|
|
}
|
|
}
|
|
|
|
// PoC for audit C3 against the CSRF middleware: a state-changing cookie request
|
|
// from a substring-spoofed origin must be rejected.
|
|
func TestCSRF_C3_SubstringSpoofedOriginRejected(t *testing.T) {
|
|
t.Parallel()
|
|
cfg, err := middleware.NewCORSConfig([]string{apexOrigin}, []string{`https://marktvogt\.de`})
|
|
if err != nil {
|
|
t.Fatalf("NewCORSConfig: %v", err)
|
|
}
|
|
|
|
r := gin.New()
|
|
r.Use(middleware.CSRF(cfg))
|
|
r.POST("/sensitive", func(c *gin.Context) { c.Status(http.StatusOK) })
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/sensitive", nil)
|
|
req.Header.Set("Origin", "https://marktvogt.de.evil.example")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("CSRF must reject spoofed origin: status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// PoC for audit H11: requests larger than the configured limit are rejected
|
|
// before the handler decodes them (no OOM blast surface).
|
|
func TestBodyLimitBytes_H11_RejectsOversized(t *testing.T) {
|
|
t.Parallel()
|
|
r := gin.New()
|
|
r.Use(middleware.BodyLimitBytes(64))
|
|
r.POST("/echo", func(c *gin.Context) {
|
|
// Force a read so MaxBytesReader's error materialises.
|
|
buf := make([]byte, 1<<20)
|
|
n, err := c.Request.Body.Read(buf)
|
|
if err != nil {
|
|
// MaxBytesReader closes the body with an error; surface as 413.
|
|
c.AbortWithStatus(http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
c.Data(http.StatusOK, "text/plain", buf[:n])
|
|
})
|
|
|
|
body := bytes.Repeat([]byte("A"), 1024) // 1 KiB body, limit is 64 B
|
|
req := httptest.NewRequest(http.MethodPost, "/echo", bytes.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("oversized body: want 413, got %d", w.Code)
|
|
}
|
|
}
|