diff --git a/server/routes/owner.ts b/server/routes/owner.ts index 9e40e48..3321518 100644 --- a/server/routes/owner.ts +++ b/server/routes/owner.ts @@ -24,14 +24,18 @@ const router = Router(); // - per-IP rate limit (req.ip via 'trust proxy' = 1, not spoofable) // - global rate limit across all IPs (defeats parallel-IP brute force) const PAIR_CODE_TTL_MS = 5 * 60 * 1000; -const MAX_FAILED_PER_CODE = 5; +const MAX_FAILED_PER_CODE = 3; // tighter — was 5 const MAX_REDEEM_PER_IP_PER_MIN = 8; const MAX_REDEEM_GLOBAL_PER_MIN = 30; +const LOCKOUT_TRIP_FAILURES_5MIN = 100; // after this many failures… +const LOCKOUT_DURATION_MS = 10 * 60 * 1000; // …block the endpoint for 10 min interface PairCodeEntry { expiresAt: number; failures: number } const pairCodes = new Map(); const redeemAttemptsByIp = new Map(); let redeemAttemptsGlobal: number[] = []; +let failuresLast5Min: number[] = []; +let lockedUntil = 0; function cleanPairCodes(): void { const now = Date.now(); @@ -84,15 +88,11 @@ router.post('/api/auth/owner/pair-code', (req, res) => { return; } cleanPairCodes(); - // Cap concurrent codes per host to a sane number. - if (pairCodes.size > 32) { - res.status(429).json({ ok: false, reason: 'too_many_codes' }); - return; - } - let code: string; - do { - code = String(crypto.randomInt(100_000, 1_000_000)); - } while (pairCodes.has(code)); + // Single-tenant app: only one active pair code at a time. Minting a new + // one burns any prior code (its window is what owner-attacker brute-force + // would target). + pairCodes.clear(); + const code = String(crypto.randomInt(100_000, 1_000_000)); pairCodes.set(code, { expiresAt: Date.now() + PAIR_CODE_TTL_MS, failures: 0 }); res.json({ ok: true, code, expiresInS: Math.floor(PAIR_CODE_TTL_MS / 1000) }); }); @@ -100,8 +100,9 @@ router.post('/api/auth/owner/pair-code', (req, res) => { // Mark a failed redeem against every currently-active code. With 6-digit // entropy, an attacker brute-forcing the keyspace doesn't know which code // the owner minted — but every wrong guess they make burns life from the -// real code. Five misses during a code's TTL is enough to invalidate it +// real code. Three misses during a code's TTL is enough to invalidate it // and force the owner to re-mint, which defeats parallel-IP brute force. +// Also record the failure on a global 5-minute window for endpoint lockout. function markFailureAgainstActiveCodes(): void { for (const [code, entry] of pairCodes) { entry.failures += 1; @@ -109,10 +110,23 @@ function markFailureAgainstActiveCodes(): void { pairCodes.delete(code); } } + const now = Date.now(); + failuresLast5Min = failuresLast5Min.filter(t => now - t < 5 * 60_000); + failuresLast5Min.push(now); + if (failuresLast5Min.length > LOCKOUT_TRIP_FAILURES_5MIN) { + lockedUntil = now + LOCKOUT_DURATION_MS; + failuresLast5Min = []; + log.error({ until: new Date(lockedUntil).toISOString() }, 'pair-redeem endpoint locked due to brute force'); + } } // ─── Pair code: redeem on a new device ────────────────────────────────────── router.post('/api/auth/owner/pair-redeem', (req, res) => { + // Hard endpoint lockout after a brute-force storm. + if (Date.now() < lockedUntil) { + res.status(429).json({ ok: false, reason: 'endpoint_locked' }); + return; + } // req.ip is trustworthy because we set 'trust proxy' = 1 in server/index.ts. // Do NOT fall back to the X-Forwarded-For header — it's client-controlled. const ip = req.ip || 'unknown';