fix: review-loop B — adversarial findings (sweep auto-refund, admin clamp, 2FA real challenge, opaque refresh tokens, gated client IP, GBP pence)

Loop B aggressive adversarial round (3 attack agents) + fix + secondary + verification:
- CRITICAL: sweep replay auto-refunds provably-created-later duplicate charges (gated on parseable CreatedAt); 22h legitimate-retry window == 22h sweep cutoff (no dead zone)
- HIGH: admin Take Payment clamps to remaining obligation (cash/giftcard/saved-card/terminal); no unintended tip from overflow; campaign credit against remaining
- HIGH: /api/services/eligible-for/{id} requires auth + owner-or-admin (DOB/age + patch-test health-data leak closed)
- HIGH: opaque refresh-token rotation (login/refresh return {token, jti, refreshToken}; refresh REQUIRES opaque token; single-use rotation; logout revokes; access token rejected at refresh)
- HIGH: saved-card charges require a REAL 2FA verification code (B6/B10) — backend gate on all 8 charge paths + shared TwoFactorCodeInput frontend component on all 7 surfaces; 2FA gate is no longer setup-flag-only
- MEDIUM: ungated CF-Connecting-IP in reserve/admin_reserve gated via exported mw.ClientIP; 2FA limiter keyed on userID alone (no header-rotation bypass); ChangePassword actually revokes JTI + refresh tokens; 2FA setup mint cooldown + persistent failed-attempt counter; campaign redemption race surfaces campaign_fully_redeemed
- Terminal saved-card VAT applied (was under-collected); age-guard reconcile failures notify; isWeakJWTSecret entropy gate; gift-card redeem per-card counter + per-user limiter; webhook signature key startup validation
- NEW internal/twofa package (single source of truth breaking the payments<->user import cycle); consolidation of duplicate 2FA hash/verify
- Frontend: refresh-token storage + rotation, TwoFactorCodeInput component, amountPaidPence in admin modal, B5/B6/B10 contract wiring; 70 frontend tests
- Tests: loop_b_fixes_test.go, internal/twofa tests, updated auth/services/profile/twofa/mw tests

All 26 backend packages pass (incl. internal/twofa); frontend 70/70 + build clean; env-docs 41/41.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent faceb9809c
commit fe88f2084d
55 changed files with 4180 additions and 971 deletions
+71 -17
View File
@@ -92,8 +92,8 @@ var weakJWTSecretValues = []string{
"super-secret",
}
// isWeakJWTSecret reports whether a JWT_SECRET_KEY is a known placeholder or
// shorter than the minimum safe length.
// isWeakJWTSecret reports whether a JWT_SECRET_KEY is a known placeholder,
// shorter than the minimum safe length, or lacks sufficient entropy.
func isWeakJWTSecret(secret string) bool {
trimmed := strings.TrimSpace(strings.ToLower(secret))
if len(trimmed) < minJWTSecretBytes {
@@ -104,7 +104,19 @@ func isWeakJWTSecret(secret string) bool {
return true
}
}
return false
// Entropy gate (B15): at least 8 distinct bytes. All-same-char and
// tiny-alphabet secrets ("aaaa...", "0000...", "abcdabcdabcd...") pass the
// length and blacklist checks but have trivial key space — HS256 keys need
// meaningful entropy, not just length. A strong random secret (even pure
// hex, which can use at most 16 distinct chars) clears the 8-byte bar.
seen := make(map[byte]struct{}, 8)
for i := 0; i < len(trimmed); i++ {
seen[trimmed[i]] = struct{}{}
if len(seen) >= 8 {
return false
}
}
return len(seen) < 8
}
func limitBody(limit int64) func(http.Handler) http.Handler {
@@ -213,6 +225,7 @@ func initSquare() {
checkSnapshotEncKey()
checkProxyRateLimitConfig()
checkWebhookSignatureKey()
}
// checkSnapshotEncKey validates SNAPSHOT_ENC_KEY at startup in non-mock
@@ -265,6 +278,30 @@ func checkProxyRateLimitConfig() {
log.Printf("WARNING: TRUST_PROXY_HEADERS is unset/false with SQUARE_ENVIRONMENT=%q (not a dev/mock value) — behind a trusted proxy (e.g. the nginx in compose.yml) every per-IP rate-limit key uses the proxy's RemoteAddr, collapsing all rate limiters to ONE global budget that any single client can exhaust for everyone. Set TRUST_PROXY_HEADERS=true when a trusted proxy sits in front and overwrites X-Real-IP/CF-Connecting-IP; keep it false only when the backend is origin-exposed.", os.Getenv("SQUARE_ENVIRONMENT"))
}
// checkWebhookSignatureKey validates SQUARE_WEBHOOK_SIGNATURE_KEY at startup in
// non-mock deployments. Square webhooks are HMAC-signed and the handler rejects
// unsigned/mis-signed events fail-closed (503 without the key, 403 on a bad
// signature), so an empty key silently disables the only integration path that
// reconciles payments and refunds from Square. Mirroring the fail-fast
// JWT_SECRET_KEY check (main.go init): when an operator HAS configured a
// notification URL (they clearly intend to receive webhooks) but left the
// signing key empty, startup FAILS — discovering mid-run that every event is
// rejected would strand payment reconciliation. When the URL is also unset
// (webhooks not in use — the documented optional setup) a loud warning is
// logged instead, so the fail-fast never breaks webhook-less deployments.
func checkWebhookSignatureKey() {
if payments.IsExplicitDevOrMockEnv() {
return
}
if os.Getenv("SQUARE_WEBHOOK_SIGNATURE_KEY") != "" {
return
}
if os.Getenv("SQUARE_WEBHOOK_NOTIFICATION_URL") != "" {
log.Fatalf("FATAL: SQUARE_WEBHOOK_SIGNATURE_KEY environment variable not set with SQUARE_ENVIRONMENT=%q (non-mock) while SQUARE_WEBHOOK_NOTIFICATION_URL IS set — Square webhook events (payment/refund reconciliation) would be rejected fail-closed at runtime. Generate the signing key in the Square Dashboard webhook subscription and set it in .env.", os.Getenv("SQUARE_ENVIRONMENT"))
}
log.Printf("CRITICAL: SQUARE_WEBHOOK_SIGNATURE_KEY is not set with SQUARE_ENVIRONMENT=%q (non-mock) and SQUARE_WEBHOOK_NOTIFICATION_URL is unset — Square webhooks are not configured; any webhook event Square sends will be rejected (503, fail-closed). Set both in .env if you rely on webhook payment/refund reconciliation.", os.Getenv("SQUARE_ENVIRONMENT"))
}
func healthCheckHandler(w http.ResponseWriter, r *http.Request) {
status := "ok"
services := map[string]string{
@@ -468,9 +505,16 @@ func main() {
r.Use(mw.OptionalAuth)
r.Get("/services", services.ServicesHandler)
r.Get("/services/popular", services.PopularServicesHandler)
r.Get("/services/eligible-for/{user_id}", services.ServicesEligibleForUserHandler)
})
// Per-user eligibility (B4): requires authentication, and the handler
// itself enforces owner-or-admin. The user-agnostic /api/services
// stays public; the per-user variant exposes DOB-derived age + patch
// test status, so an arbitrary user_id must never be queryable
// unauthenticated (the frontend calls it only with the current user's
// ID, or via the admin booking flows).
r.With(mw.RateLimit(120, time.Minute), mw.RequireAuth).Get("/services/eligible-for/{user_id}", services.ServicesEligibleForUserHandler)
// Registration: 10/min to prevent spam + progressive per-IP backoff
r.With(mw.ProgressiveRateLimit, mw.RateLimit(10, time.Minute), limitBody(defaultBodyLimit)).Post("/register", authHandlers.RegisterHandler)
@@ -553,14 +597,19 @@ func main() {
// Public email check (used by BookingFlow for proactive registered-email detection)
r.With(mw.RateLimit(60, time.Minute)).Get("/check-email", user.CheckEmailHandler)
// Refresh-token exchange (B5): the client presents the opaque refresh
// token in the Authorization header (Bearer). This MUST be outside the
// RequireAuth group — the refresh token is NOT a JWT, so RequireAuth
// would reject it. The handler itself validates + rotates the refresh
// token and mints a fresh access-token/refresh-token pair.
r.With(mw.RateLimit(10, time.Minute)).Post("/refresh-token", authHandlers.RefreshTokenHandler)
// Authenticated users
r.Group(func(r chi.Router) {
r.Use(mw.RequireAuth)
r.Use(mw.RateLimit(120, time.Minute))
r.Use(limitBody(defaultBodyLimit))
r.Post("/refresh-token", authHandlers.RefreshTokenHandler)
r.Get("/user/profile", user.GetProfileHandler)
r.Put("/user/profile", user.UpdateProfileHandler)
r.Put("/user/change-password", user.ChangePasswordHandler)
@@ -574,19 +623,19 @@ func main() {
// RequireNonGuest: any logged-in user who could save cards must be
// able to reach these, not just verified accounts.
r.With(mw.RequireNonGuest).Get("/user/2fa/status", user.GetTwoFAStatusHandler)
// The code-issuing/verifying endpoints get a dedicated per-user+IP
// The code-issuing/verifying endpoints get a dedicated per-user
// limiter (10/min) on top of the group's generic 120/min limiter:
// the 6-digit codes live in a 1M space, so a single user must not be
// able to hammer setup/verify/disable faster than the per-user
// 5-attempt lockout can trip. The key combines the authenticated
// userID with the client IP: even behind a proxy that does not set
// TRUST_PROXY_HEADERS=true (so every request's RemoteAddr is the
// proxy's IP), the budget stays per-account — one account holder can
// never exhaust a shared GLOBAL bucket that 429s the entire 2FA
// surface (setup/verify/disable, and thus saved-card payments) for
// everyone. One shared limiter for all four so the whole 2FA surface
// counts against a single per-user budget.
twoFALimiter := mw.RateLimitByUserAndIP(10, time.Minute)
// 5-attempt lockout can trip. The key is the authenticated userID
// ALONE (RateLimitByUser) — NOT user+IP (B8): with the IP in the
// key, a client that can rotate its source IP (or that sits behind
// a proxy echoing a client-supplied CF-Connecting-IP when
// TRUST_PROXY_HEADERS=true) mints a fresh bucket per IP for the
// same account, collapsing the per-account budget. One shared
// limiter for all four so the whole 2FA surface counts against a
// single per-user budget.
twoFALimiter := mw.RateLimitByUser(10, time.Minute)
r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/setup", user.SetupTwoFAHandler)
r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/verify", user.VerifyTwoFAHandler)
r.With(mw.RequireNonGuest, twoFALimiter).Post("/user/2fa/disable", user.DisableTwoFAHandler)
@@ -626,7 +675,12 @@ func main() {
Get("/bookings/{id}/discount-preview", payments.GetDiscountPreviewHandler)
// User gift card routes
r.With(mw.RequireNonGuest).Post("/user/giftcards/redeem", payments.RedeemGiftCard)
// Dedicated redeem limiter (B16): a gift-card code lives in the
// 12-hex space, so redemption is brute-forceable. The redeem route
// gets a per-user 10/min budget (RateLimitByUser — one bucket per
// account regardless of IP rotation) on top of the group's generic
// 120/min limiter.
r.With(mw.RequireNonGuest, mw.RateLimitByUser(10, time.Minute)).Post("/user/giftcards/redeem", payments.RedeemGiftCard)
r.Get("/user/giftcards/balance", payments.GetGiftCardBalance)
r.With(mw.RequireNonGuest).Post("/user/giftcards/buy", payments.BuyGiftCard)
// 14-day cooling-off right to cancel online gift-card purchases