fix: review round — B1 clock-skew tolerance + re-poll escalation, refresh-token access-token revocation, shared 2FA composable, per-package-DB test alignment
Three fresh reviews (money/security/dup-mod) cross-validated findings: - MEDIUM: B1 'new charge' discrimination adds a lower-bound tolerance (replayRescueLowerBoundSkew) so a retained-key replay of the ORIGINAL charge (DB clock ahead of Square) is never auto-refunded; ambiguous margins leave PENDING + CRITICAL - MEDIUM: B1 re-poll escalates after stalePendingB1RefundAge (48h) — FAILED/REJECTED refunds go terminal (fail parent, claw back till-sale funding, CRITICAL notification); no more unbounded re-polling / stranded parents without webhooks - DRIFT-REAL: processManualPaymentGroup now checks PENDING/FAILED/REJECTED on the synchronous refund response (mirrors processChargeGroup/manual handler) — no more premature 'completed' - HIGH: refresh-token family kill now also invalidates the attacker's freshly-minted ACCESS token — access tokens carry a family_id claim and VerifyToken rejects tokens whose family was deleted (GenerateTokenForFamily + family-alive check); 30s grace window for concurrent two-tab refresh (no false theft alert) - LOW: 2FA mint endpoint returns remaining_seconds; in-memory 2FA counters documented; 90-day refresh expiry single-sourced (RefreshTokenLifetime + make_interval) - Dup/mod: NEW shared useTwoFactorCodeForSavedCard Svelte composable replaces 6 surface copies of the 2FA gate logic (Request-a-new-code added to BookingFlow + TillPurchases); account page adopts generateUUID - Test architecture: removed t.Parallel() from 8 global-SquareClient-swapping tests per Testing Architecture doc line 89 (B1 flaky-test lesson) — fixes within-package race - SQL alias pence rename (total_cents/paid_cents -> total_pence/paid_pence) 26/26 backend packages; 72/72 frontend tests + build; env-docs 41/41.
This commit is contained in:
@@ -267,11 +267,14 @@ func RegisterAll(s *Scheduler) {
|
||||
})
|
||||
}
|
||||
|
||||
// SweepSquareWebhookEvents deletes square_webhook_events rows older than 90
|
||||
// days. Every accepted webhook event_id is stored permanently for restart-safe
|
||||
// dedup (payment.updated fires on every payment update), so without retention
|
||||
// the table would grow without bound. 90 days comfortably exceeds Square's
|
||||
// webhook replay window while keeping the table bounded.
|
||||
// SweepSquareWebhookEvents deletes square_webhook_events rows older than the
|
||||
// shared auth.RefreshTokenLifetime window (90 days). Every accepted webhook
|
||||
// event_id is stored permanently for restart-safe dedup (payment.updated fires
|
||||
// on every payment update), so without retention the table would grow without
|
||||
// bound. 90 days comfortably exceeds Square's webhook replay window while
|
||||
// keeping the table bounded. The 90-day window deliberately reuses
|
||||
// auth.RefreshTokenLifetime so the two coinciding retention windows cannot
|
||||
// drift apart.
|
||||
func SweepSquareWebhookEvents(ctx context.Context) (int, error) {
|
||||
tx, err := db.Conn.Begin(ctx)
|
||||
if err != nil {
|
||||
@@ -285,8 +288,8 @@ func SweepSquareWebhookEvents(ctx context.Context) (int, error) {
|
||||
|
||||
tag, err := tx.Exec(ctx, `
|
||||
DELETE FROM square_webhook_events
|
||||
WHERE received_at < NOW() - INTERVAL '90 days'
|
||||
`)
|
||||
WHERE received_at < NOW() - make_interval(days => $1)
|
||||
`, int64(auth.RefreshTokenLifetime/(24*time.Hour)))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to sweep square webhook events: %w", err)
|
||||
}
|
||||
|
||||
@@ -65,9 +65,20 @@ const MaxAttempts = 5
|
||||
const AttemptWindow = 10 * time.Minute
|
||||
|
||||
// MaxTrackedAttempts caps the in-memory attempt map so a flood of distinct
|
||||
// user IDs cannot grow it without bound. Counters are purely in-memory (the DB
|
||||
// schema is locked — there is no attempt column), so they reset on process
|
||||
// restart; the 10-minute pending-code expiry bounds the practical impact.
|
||||
// user IDs cannot grow it without bound.
|
||||
//
|
||||
// ACCEPTED LIMITATION (LOW 6 — documentation only, no behavior change): every
|
||||
// 2FA counter here — the per-user failed-attempt count, the lockout window, and
|
||||
// the mint-cooldown stamp (AttemptState.LastMintAt, used by twoFAMintCooldown
|
||||
// in handlers/user) — is purely in-memory and resets on process restart. The
|
||||
// DB schema is locked (there is no attempt column), and the practical impact
|
||||
// is bounded by the 10-minute pending-code expiry (AttemptWindow /
|
||||
// twoFAPendingExpiry): at most one fresh 5-guess budget per 10-minute window.
|
||||
// A MULTI-INSTANCE deployment would need a shared store (e.g. a DB column or
|
||||
// Redis) for these counters, because today each instance keeps its own map —
|
||||
// an attacker could distribute guesses across instances. Single-instance
|
||||
// deployments (this app) are unaffected.
|
||||
//
|
||||
// Declared as a var so the eviction policy is unit-testable at a small cap.
|
||||
var MaxTrackedAttempts = 10_000
|
||||
|
||||
|
||||
Reference in New Issue
Block a user