mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-17 03:32:32 -06:00
The 6-digit pairing code is generated client-side, so the server can't raise its entropy without a player change. Instead, harden server-side (no client change): - lib/pair-lockout.js: lock an IP out of POST /api/provision/pair after 5 failed claims (15-min lockout), and expire stale provisioning codes after 15 min so a code is not claimable indefinitely. A successful claim resets the IP. - /pair enforces both. Only an UNKNOWN code (404) counts toward the lockout (a real guess); an EXPIRED code (410) is a legitimate-but-stale code and does NOT count, so a slow bulk rollout from one shared-NAT IP can't lock itself out. getClientIp is Cloudflare-aware (CF-Connecting-IP validated against a trusted edge peer), so the lockout keys on the real per-client IP, never a shared edge. Unit-tested deterministically with injected time, incl. the bulk-rollout-never-locks case. Closes #87 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
40 lines
1.8 KiB
JavaScript
40 lines
1.8 KiB
JavaScript
'use strict';
|
|
|
|
// #87: brute-force hardening for device pairing. The 6-digit pairing code is generated
|
|
// client-side, so the server can't raise its entropy without a player change - but it can
|
|
// (a) lock out an IP after repeated failed claims and (b) expire stale provisioning codes
|
|
// so a code is not claimable indefinitely. Together with the 5/min rate-limit on
|
|
// /api/provision (#88), guessing the ~1M code space becomes infeasible (a locked-out IP
|
|
// gets ~5 tries per 15 min, and each code only lives 15 min).
|
|
|
|
const MAX_FAILS = 5; // consecutive failed claims from an IP before lockout
|
|
const LOCKOUT_MS = 15 * 60 * 1000; // how long the IP is then blocked from /pair
|
|
const PAIRING_TTL_SEC = 15 * 60; // how long a provisioning code stays claimable
|
|
|
|
const failures = new Map(); // ip -> { count, lockedUntil }
|
|
|
|
function isLocked(ip, now = Date.now()) {
|
|
const rec = failures.get(ip);
|
|
return !!(rec && rec.lockedUntil > now);
|
|
}
|
|
|
|
// Record one failed claim from an IP; trip the lockout once MAX_FAILS is reached.
|
|
function recordFailure(ip, now = Date.now()) {
|
|
const rec = failures.get(ip) || { count: 0, lockedUntil: 0 };
|
|
rec.count += 1;
|
|
if (rec.count >= MAX_FAILS) { rec.lockedUntil = now + LOCKOUT_MS; rec.count = 0; }
|
|
failures.set(ip, rec);
|
|
return rec;
|
|
}
|
|
|
|
// A successful pair (or any reason to forgive an IP) clears its failure record.
|
|
function reset(ip) { failures.delete(ip); }
|
|
|
|
// A provisioning code is stale once it is older than the TTL (devices.created_at is the
|
|
// register time for a provisioning device).
|
|
function isCodeExpired(createdAtSec, now = Date.now()) {
|
|
return Math.floor(now / 1000) - createdAtSec > PAIRING_TTL_SEC;
|
|
}
|
|
|
|
module.exports = { isLocked, recordFailure, reset, isCodeExpired, MAX_FAILS, LOCKOUT_MS, PAIRING_TTL_SEC };
|