SPB Git

Repository Public

throttle.ts 2.2 KB 52 lines typescript

1/**2 * Brute-force throttling backed by `login_attempts` (survives restarts).3 *   per IP        : 10 failures / 15 min4 *   per username  :  5 failures / 15 min5 *   global breaker: 100 failures / 15 min6 * Once a key reaches its limit it is locked progressively: 60 s, doubled for every further7 * failure, capped at 15 min. A success clears the failures of the ip/user keys (not `global`).8 * Applies to password, TOTP, setup-token and passkey verification alike.9 */10import type { Queryable } from '../db.js';1112const WINDOW_SEC = 15 * 60;13export const THROTTLE_LIMITS = { ip: 10, user: 5, global: 100 } as const;1415export interface ThrottleKeys {16  ip: string;17  username?: string | null;18}19function keysOf(k: ThrottleKeys): { key: string; limit: number }[] {20  const out: { key: string; limit: number }[] = [21    { key: 'global', limit: THROTTLE_LIMITS.global },22    { key: `ip:${k.ip}`, limit: THROTTLE_LIMITS.ip },23  ];24  if (k.username) out.push({ key: `user:${k.username.toLowerCase().slice(0, 64)}`, limit: THROTTLE_LIMITS.user });25  return out;26}2728/** Seconds the caller must wait (0 = allowed). */29export async function throttleCheck(db: Queryable, k: ThrottleKeys): Promise<number> {30  let wait = 0;31  for (const { key, limit } of keysOf(k)) {32    const row = await db.one<{ failures: number; last_age: number | null }>(33      `SELECT count(*)::int AS failures, extract(epoch FROM now() - max(at))::float8 AS last_age34         FROM login_attempts35        WHERE key = $1 AND NOT ok AND at > now() - make_interval(secs => $2)36          AND at > coalesce((SELECT max(at) FROM login_attempts WHERE key = $1 AND ok), '-infinity')`,37      [key, WINDOW_SEC],38    );39    if (!row || row.failures < limit || row.last_age === null) continue;40    const lockSec = Math.min(60 * 2 ** Math.min(row.failures - limit, 10), WINDOW_SEC);41    wait = Math.max(wait, Math.ceil(lockSec - row.last_age));42  }43  return Math.max(0, wait);44}4546export async function throttleRecord(db: Queryable, k: ThrottleKeys, ok: boolean): Promise<void> {47  const keys = keysOf(k)48    .map((x) => x.key)49    .filter((key) => !ok || key !== 'global');50  await db.q('INSERT INTO login_attempts (key, ok) SELECT unnest($1::text[]), $2', [keys, ok]);51}52
Files main
Menu

Theme