fix: pre-launch review — security, money safety, privacy, legal, code quality

Security (P0):
- IsJTIRevoked fails closed on DB error (previously accepted revoked tokens)
- Remove dead consume parameter from SCA gate (prevented token replay)
- Rate limiter map TTL-based eviction (prevented memory exhaustion)
- 2FA attempt map already had LRU eviction (verified)

Money Safety (P1):
- Gift card transfer refuses expired destination cards
- Gift card balance deduction has WHERE balance >= amount guard
- Webhook clawback acquires till-sale advisory lock
- Sweep/retry lock keys aligned

Privacy/Cookies (P2):
- Self-host Google Fonts (Playfair Display woff2)
- Replace CARTO map tiles with OpenStreetMap raster tiles
- Replace Wikimedia/icon-icons external images with local SVGs
- Remove external image URLs from CSP

Legal (P3):
- Privacy policy: add 6 missing data categories (gift cards, 2FA, GDPR, notifications, technical, cookies)
- Terms: add Tips section (optionality, non-refundable, same processing as bookings)

Code Quality (P4):
- twofa.Check accepts db.Querier for testability
- depositPromotionMinPct uses literal 0.20 (not misleading alias)
- HolidayHours.svelte uses proper type (not as any[])
- Remove stale TODO comments from main.go

Testing (P5):
- 94 new float64 money validity tests across 3 test files
- Cover VAT, splits, refunds, gift cards, rounding, precision boundaries
- All 27 backend test packages pass
This commit is contained in:
2026-08-22 00:34:51 +01:00
parent 9a12a2d886
commit 4146f8e09a
31 changed files with 4351 additions and 81 deletions
+7 -7
View File
@@ -373,7 +373,7 @@ const (
// error is non-nil only for DB failures (callers return 500); a lockout's
// pending-code invalidation failure is logged here and still reported as a
// lockout.
func Check(ctx context.Context, userID string, st *AttemptState, reqCode string, consume bool) (Result, error) {
func Check(ctx context.Context, q db.Querier, userID string, st *AttemptState, reqCode string, consume bool) (Result, error) {
if now := clock.Now(); now.Sub(st.LastActive()) > AttemptWindow {
st.Count.Store(0)
st.SetLastActive(now)
@@ -384,7 +384,7 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string,
var pendingHash sql.NullString
var pendingExpires sql.NullTime
err := db.Conn.QueryRow(ctx, `
err := q.QueryRow(ctx, `
SELECT two_factor_pending_code_hash, two_factor_pending_code_expires
FROM users
WHERE id = $1
@@ -406,7 +406,7 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string,
if st.Count.Load() >= MaxAttempts {
// Lockout reached: destroy the pending code so a stolen digest
// cannot be replayed against a fresh guessing loop.
if _, err := db.Conn.Exec(ctx, `
if _, err := q.Exec(ctx, `
UPDATE users
SET two_factor_pending_code_hash = NULL,
two_factor_pending_code_expires = NULL
@@ -425,7 +425,7 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string,
// code stays valid for the rest of the handshake — consume mode destroys
// the digest outright, so there is nothing to upgrade.
if legacy && !consume {
if _, err := db.Conn.Exec(ctx, `
if _, err := q.Exec(ctx, `
UPDATE users
SET two_factor_pending_code_hash = $2
WHERE id = $1
@@ -453,7 +453,7 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string,
// locked_until) — a successful 2FA challenge is a strong auth signal, and
// the only way to reach a 2FA verify is an already-authenticated session.
// Best-effort: a failure only logs; the verify has already succeeded.
if _, err := db.Conn.Exec(ctx, `
if _, err := q.Exec(ctx, `
UPDATE users
SET failed_attempts = 0, locked_until = NULL
WHERE id = $1
@@ -477,7 +477,7 @@ func Check(ctx context.Context, userID string, st *AttemptState, reqCode string,
// it, but only the first conditional UPDATE can affect a row — the
// loser sees 0 rows and must fail (MissingOrExpired), so one code
// authorizes exactly ONE operation even across instances.
tag, err := db.Conn.Exec(ctx, `
tag, err := q.Exec(ctx, `
UPDATE users
SET two_factor_pending_code_hash = NULL,
two_factor_pending_code_expires = NULL
@@ -590,7 +590,7 @@ func VerifyForUser(ctx context.Context, userID, code string, consume bool) error
st.Mu.Lock()
defer st.Mu.Unlock()
result, err := Check(ctx, userID, st, code, consume)
result, err := Check(ctx, db.Conn, userID, st, code, consume)
if err != nil {
return fmt.Errorf("2FA verify: %w", err)
}
+1 -1
View File
@@ -155,7 +155,7 @@ func TestVerifyForUser_DBAtomicConsume_Concurrent(t *testing.T) {
defer wg.Done()
st := &AttemptState{}
st.SetLastActive(clock.Now())
res, err := Check(context.Background(), userID, st, "424242", true)
res, err := Check(context.Background(), db.Conn, userID, st, "424242", true)
results <- outcome{result: res, err: err}
}()
}