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:
+71
-17
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user