fix(security): tighten pair-code defences per follow-up review

- Per-code failure threshold dropped 5 → 3. After 3 wrong guesses
  during a code's 5-min TTL, the code is burned and the owner must
  re-mint. Still tolerates the occasional typo while collapsing the
  brute-force window further.
- Cap concurrent active codes to 1: minting a new code clears any
  prior code. Single-user app, no need to juggle multiple.
- Add a global brute-force lockout: after 100 failed attempts in
  any 5-minute window, the redeem endpoint returns 429 endpoint_locked
  for the next 10 minutes, regardless of source IP. Logged at error
  level so the operator notices.
This commit is contained in:
2026-06-01 10:21:03 +01:00
parent 36118b4a4f
commit 9f489d1fb6
+25 -11
View File
@@ -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<string, PairCodeEntry>();
const redeemAttemptsByIp = new Map<string, number[]>();
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';