fix: comprehensive payment system hardening (4 review passes)
CRITICAL fixes: - C1: JWT exp claim now validated via jwtauth.VerifyToken (was Decode) - C2: OverrideAmount validated post-substitution (prevents negative money minting) - C3: Terminal gift-card payments store gift_card_id; refund credits user balance - C4: Refund dedup returns stored amount, not req.Amount (prevents admin mislead) - C5: Booking recheck uses FOR UPDATE (prevents TOCTOU with cancellation) - C6: processChargeGroup idempotency key stable (charge-only, prevents double-refund) MAJOR fixes: - M2: Gift-card refund UPDATE checks RowsAffected; 0 rows -> failed - M3: ProcessCancellationRefund returns commit error (was swallowed) - M5: Dispute webhook handling (created + state.updated + disputes table) MEDIUM fixes: - ME1: CORS restricted to FRONTEND_ORIGIN env var (was reflect-any) - ME2: anonymize_user() scrubs users.notes, bookings.notes, name_history, refresh_tokens - ME3: Webhook handlers now mutate state (payment.updated, refund.updated) Frontend fixes: - Same-key retry on 503 (ambiguous failure) wired to all 8 payment flows - CHARGE_AND_STORE intent for save-card flows (SCA compliance) - Nonce staleness check verified across all flows Additional fixes from adversarial re-review: - F1: Till-sale completed dedup echoes stored amount (C4-class) - F2: Cash/giftcard terminal path uses FOR UPDATE (C5-class) - F3: Square-success UPDATE checks RowsAffected (till sales) - F4: Dispute reason truncated to 192 chars (prevents INSERT failure) - F5: Booking-user lookup failure marks refund failed (prevents silent money loss) - F6: Saved-card/tip rechecks wrapped in transaction (C5 residual) Tests: - 15 adversarial attack tests (negative override, zero override, terminal gift card, refund dedup, TOCTOU, deleted gift card, advisory lock, overcharge, zero/negative/huge amount, raw PAN, missing auth, gift card balance, concurrent refunds) - 14 webhook state tests (dispute created/state, payment/refund updated) - 3 CORS tests, 3 GDPR tests, 1 HTTP timeout test - Full suite passes with -race (25 packages, 0 failures) 25 files changed, +1532/-275 lines
This commit is contained in:
@@ -101,3 +101,85 @@ func TestHealthCheck_Degraded(t *testing.T) {
|
||||
// Restore original db.Conn
|
||||
db.Conn = originalDB
|
||||
}
|
||||
|
||||
func TestCORS_OnlyAllowedOrigins(t *testing.T) {
|
||||
t.Setenv("FRONTEND_ORIGIN", "https://app.example.com, http://localhost:5173")
|
||||
handler := corsMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
origin string
|
||||
expectAllowed bool
|
||||
}{
|
||||
{name: "first configured origin is allowed", origin: "https://app.example.com", expectAllowed: true},
|
||||
{name: "second configured origin is allowed", origin: "http://localhost:5173", expectAllowed: true},
|
||||
{name: "unlisted origin is rejected", origin: "https://evil.example.com", expectAllowed: false},
|
||||
{name: "prefix-confusion origin is rejected", origin: "https://app.example.com.evil.test", expectAllowed: false},
|
||||
{name: "suffix-attack origin is rejected", origin: "https://app.example.com/evil", expectAllowed: false},
|
||||
{name: "no origin header is not echoed", origin: "", expectAllowed: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||
if tt.origin != "" {
|
||||
req.Header.Set("Origin", tt.origin)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
got := rr.Header().Get("Access-Control-Allow-Origin")
|
||||
if tt.expectAllowed && got != tt.origin {
|
||||
t.Errorf("expected Access-Control-Allow-Origin %q, got %q", tt.origin, got)
|
||||
}
|
||||
if !tt.expectAllowed && got != "" {
|
||||
t.Errorf("expected no Access-Control-Allow-Origin header, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_DefaultOriginWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv("FRONTEND_ORIGIN", "")
|
||||
handler := corsMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
allowed := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
allowed.Header.Set("Origin", "http://localhost:5173")
|
||||
aw := httptest.NewRecorder()
|
||||
handler.ServeHTTP(aw, allowed)
|
||||
if got := aw.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:5173" {
|
||||
t.Errorf("expected default dev origin to be allowed, got %q", got)
|
||||
}
|
||||
|
||||
evil := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
evil.Header.Set("Origin", "https://evil.example.com")
|
||||
ew := httptest.NewRecorder()
|
||||
handler.ServeHTTP(ew, evil)
|
||||
if got := ew.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("expected unlisted origin to be rejected with default config, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSPreflight_RejectsUnlistedOrigin(t *testing.T) {
|
||||
t.Setenv("FRONTEND_ORIGIN", "https://app.example.com")
|
||||
handler := corsMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "/api/bookings", nil)
|
||||
req.Header.Set("Origin", "https://evil.example.com")
|
||||
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Errorf("expected preflight 204, got %d", rr.Code)
|
||||
}
|
||||
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("expected no Access-Control-Allow-Origin on preflight for unlisted origin, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user