From e97a3dc89f0ed27be48f7a5600a21a2e875d0260 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Tue, 7 Jul 2026 15:51:16 +0000 Subject: [PATCH] Add rate limiting: login (5 fails/15min per email+IP), register + verify per-IP throttles Co-Authored-By: Claude Sonnet 4.6 --- src/routes/auth.js | 69 +++++++++++++++++++++++++++++++++++++++++- src/routes/register.js | 13 +++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/routes/auth.js b/src/routes/auth.js index 8fb9c64..ca05c6a 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -6,6 +6,64 @@ const DOMAIN = process.env.DOMAIN || 'localhost' const SESSION_DAYS = parseInt(process.env.SESSION_DAYS || '30') const COOKIE_MAX_AGE = SESSION_DAYS * 24 * 60 * 60 +// In-memory login throttle: 5 failures per email+IP within 15 minutes. +// Single-container deployment, so no shared store needed. +const LOGIN_MAX_FAILURES = 5 +const LOGIN_WINDOW_MS = 15 * 60_000 +const _loginFailures = new Map() // key → [timestamps] + +function loginKey(request, email) { + const ip = request.headers['x-forwarded-for']?.split(',')[0].trim() || request.ip + return `${ip}|${email}` +} + +function isLoginBlocked(key) { + const now = Date.now() + const recent = (_loginFailures.get(key) ?? []).filter(t => now - t < LOGIN_WINDOW_MS) + if (recent.length) _loginFailures.set(key, recent) + else _loginFailures.delete(key) + return recent.length >= LOGIN_MAX_FAILURES +} + +function recordLoginFailure(key) { + const list = _loginFailures.get(key) ?? [] + list.push(Date.now()) + _loginFailures.set(key, list) +} + +// Prune stale entries so the map can't grow unbounded +setInterval(() => { + const now = Date.now() + for (const [key, list] of _loginFailures) { + const recent = list.filter(t => now - t < LOGIN_WINDOW_MS) + if (recent.length) _loginFailures.set(key, recent) + else _loginFailures.delete(key) + } +}, 10 * 60_000).unref() + +// Generic per-IP throttle for public endpoints (register, etc.) +export function makeIpThrottle(maxRequests, windowMs) { + const hits = new Map() // ip → [timestamps] + setInterval(() => { + const now = Date.now() + for (const [ip, list] of hits) { + const recent = list.filter(t => now - t < windowMs) + if (recent.length) hits.set(ip, recent) + else hits.delete(ip) + } + }, 10 * 60_000).unref() + + return function check(request) { + const ip = request.headers['x-forwarded-for']?.split(',')[0].trim() || request.ip + const now = Date.now() + const recent = (hits.get(ip) ?? []).filter(t => now - t < windowMs) + if (recent.length >= maxRequests) return false + recent.push(now) + hits.set(ip, recent) + return true + } +} + export function cookieOpts(request, clear = false) { // Mark the cookie Secure only when the request actually arrived over HTTPS // (via NPM's X-Forwarded-Proto). Over plain HTTP on the LAN, a Secure cookie @@ -92,15 +150,24 @@ export async function authRoutes(app) { const { email, password } = request.body || {} if (!email || !password) return reply.status(400).send({ error: 'Email and password required' }) + const normalised = email.toLowerCase().trim() + const throttleKey = loginKey(request, normalised) + if (isLoginBlocked(throttleKey)) { + return reply.status(429).send({ error: 'Too many failed attempts — try again in 15 minutes' }) + } + const { rows: [user] } = await pool.query( 'SELECT id, password_hash, active FROM users WHERE email = $1', - [email.toLowerCase().trim()] + [normalised] ) if (!user || !user.active || !(await verifyPassword(password, user.password_hash))) { + recordLoginFailure(throttleKey) return reply.status(401).send({ error: 'Invalid email or password' }) } + _loginFailures.delete(throttleKey) + const full = await getUserWithApps(user.id) const token = await signToken({ sub: full.email, diff --git a/src/routes/register.js b/src/routes/register.js index 4d3b76f..6260902 100644 --- a/src/routes/register.js +++ b/src/routes/register.js @@ -2,7 +2,12 @@ import { pool } from '../db.js' import { hashPassword, verifyPassword, signToken } from '../jwt.js' import { findEmployeeByEmail, getEmployeeDepartments } from '../workforce.js' import { sendPinEmail } from '../email.js' -import { getUserWithApps, cookieOpts } from './auth.js' +import { getUserWithApps, cookieOpts, makeIpThrottle } from './auth.js' + +// 10 registration requests per IP per hour — each triggers a Workforce lookup + email +const registerThrottle = makeIpThrottle(10, 60 * 60_000) +// 20 verify attempts per IP per hour — PIN guessing across emails +const verifyThrottle = makeIpThrottle(20, 60 * 60_000) export async function registerRoutes(app) { // POST /api/auth/register @@ -15,6 +20,9 @@ export async function registerRoutes(app) { const respond = () => reply.send({ sent: true }) + // Silently drop over-limit requests — same response as success, no enumeration signal + if (!registerThrottle(request)) return respond() + // Rate limit: if a PIN was sent within the last 2 minutes, silently pretend we sent again. // Prevents rapid-fire spam while still allowing genuine "I didn't get it" retries after cooldown. const { rows: [existing] } = await pool.query( @@ -67,6 +75,9 @@ export async function registerRoutes(app) { if (!email || !pin || !password) { return reply.status(400).send({ error: 'Email, PIN and password are required' }) } + if (!verifyThrottle(request)) { + return reply.status(429).send({ error: 'Too many attempts — try again later' }) + } if (password.length < 8) { return reply.status(400).send({ error: 'Password must be at least 8 characters' }) }