fix: loop-B adversarial findings — tip-type double-charge, tip-refund capacity, loyalty stamp farming, gate ordering, auth amplification, admin audit log

Loop B restart (money/security/dup-mod adversarial) fixes:
- CRITICAL: CreateTerminalPayment rejects payment_type='tip' (mirrors CreateBookingPayment) — a tip-typed admin charge no longer records the FULL amount as a tip and double-collects (all is-paid computations exclude tip rows)
- HIGH: tip refunds can no longer re-open booking capacity — refunded_total subqueries filter payment_type <> 'tip' (service.go) and RefundPayment rejects tip rows
- MEDIUM: loyalty-stamp farming closed — stamp award once-per-booking via loyalty_stamp_awarded_at column (init-script.sql) + existing same-day guard
- MEDIUM: CreateTipPayment/CreateBookingPayment 2FA gates moved AFTER the idempotency completed-dedup (code consumed only on new money paths; terminal path already correct) — lost-response retries return the completed payment instead of 400
- MEDIUM: replayRescueLowerBoundSkew widened to 5m (DB-clock-skew stranded originals now rescued)
- MEDIUM-1: verifyFamilyAlive DB amplification reduced via 30s bounded family-alive cache; admin route group rate-limited
- MEDIUM-3: admin saved-card charges now write admin_audit_log (handlers.go helper + till); [2FA] log line decoupled from user identity
- LOW-1: logout scoped to the presented token's family (no cross-session kill)
- LOW-2: refresh-reuse grace widened for same-IP replays
- LOW-4: squareEnvironmentMismatch enforced for empty env
- LOW-5: uuid.ts hard-fails on Math.random fallback (crypto.randomUUID)
- Cash/giftcard tip-enabled overflow mirrors the card-terminal carve

26/26 backend packages; 72/72 frontend tests + build; env-docs 41/41.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 03d85c6d13
commit 7c424b28b8
23 changed files with 1151 additions and 225 deletions
+16 -15
View File
@@ -153,17 +153,17 @@ func webhookDBContext() (context.Context, context.CancelFunc) {
// squareEnvironmentMismatch reports whether the webhook's square-environment
// header conflicts with the deployment's configured SQUARE_ENVIRONMENT.
//
// The check is enforced ONLY when the configured environment is a known real
// Square environment (production/sandbox): those are the deployments where a
// mis-pointed subscription (e.g. a sandbox subscription posting to the
// production URL + signing key) would process events against the wrong state.
// In dev/mock deployments — or an empty/unknown SQUARE_ENVIRONMENT, which the
// rest of the backend treats as fail-closed production but is not a specific
// real environment to compare against — the header is informational and a
// mismatch is not rejectable. The dev/mock determination delegates to the
// shared payments.IsExplicitDevOrMockEnv (handlers/payments/twofa.go) rather
// than re-implementing the env-value list, so this check and the 2FA gate can
// never diverge on what counts as the dev/mock stack.
// The check is enforced for every non-dev/mock deployment: configured
// production and sandbox are compared directly, and an EMPTY or UNKNOWN
// configured SQUARE_ENVIRONMENT is treated as PRODUCTION (LOW-4) — matching how
// the rest of the backend treats empty/unknown env fail-closed (main.go:214
// and payments twofa.go:40-42) — so a sandbox subscription mis-pointed at an
// unconfigured production URL cannot process sandbox events against production
// state. In dev/mock deployments the header is informational and a mismatch is
// not rejectable; the dev/mock determination delegates to the shared
// payments.IsExplicitDevOrMockEnv (handlers/payments/twofa.go) rather than
// re-implementing the env-value list, so this check and the 2FA gate can never
// diverge on what counts as the dev/mock stack.
//
// An absent header is allowed through: real Square deliveries always send it,
// so a missing header in an enforced deployment is a non-Square client (which
@@ -189,10 +189,11 @@ func squareEnvironmentMismatch(headerEnv string) bool {
return false
}
configured := strings.ToLower(strings.TrimSpace(os.Getenv("SQUARE_ENVIRONMENT")))
if configured != "production" && configured != "sandbox" {
// Empty/unknown configured environment — no specific real environment
// to enforce against; the header is informational only.
return false
if configured != "sandbox" {
// Empty/unknown configured environment is treated as PRODUCTION for the
// env check (LOW-4), matching the fail-closed production default the
// rest of the backend applies to empty/unknown SQUARE_ENVIRONMENT.
configured = "production"
}
return headerEnv != configured
}
@@ -998,6 +998,45 @@ func TestHandleSquareWebhook_EnvMismatch_DevNotEnforced(t *testing.T) {
}
}
// TestSquareEnvironmentMismatch_EmptyConfigTreatedAsProduction pins the LOW-4
// fix: an EMPTY/UNKNOWN configured SQUARE_ENVIRONMENT is treated as
// PRODUCTION for the webhook env check (matching main.go:214 and payments
// twofa.go:40-42's fail-closed default), so a sandbox subscription mis-pointed
// at an unconfigured production URL is rejected instead of silently accepted.
func TestSquareEnvironmentMismatch_EmptyConfigTreatedAsProduction(t *testing.T) {
t.Setenv("SQUARE_ENVIRONMENT", "")
// Empty/unknown configured env behaves exactly like "production": a
// sandbox header is a mismatch, a production header is a match.
if !squareEnvironmentMismatch("sandbox") {
t.Error("empty SQUARE_ENVIRONMENT must be treated as production (sandbox header = mismatch)")
}
if squareEnvironmentMismatch("production") {
t.Error("empty SQUARE_ENVIRONMENT treated as production must match a production header")
}
if squareEnvironmentMismatch("PRODUCTION") {
t.Error("header comparison must stay case-insensitive")
}
if squareEnvironmentMismatch("") {
t.Error("an absent header must stay allowed")
}
// An explicit sandbox config still enforces sandbox semantics.
t.Setenv("SQUARE_ENVIRONMENT", "sandbox")
if squareEnvironmentMismatch("sandbox") {
t.Error("sandbox config must match a sandbox header")
}
if !squareEnvironmentMismatch("production") {
t.Error("sandbox config must reject a production header")
}
// An explicit dev/mock config never enforces the header check.
t.Setenv("SQUARE_ENVIRONMENT", "mock")
if squareEnvironmentMismatch("sandbox") {
t.Error("dev/mock deployments must not enforce the header check")
}
}
// =============================================================================
// Bounded post-dispatch DB contexts (finding b)
// =============================================================================