Limiting login attempts
Security · Difficulty 5/5 · JavaScript · 42 linesOrigin: Fictional
Task
What the AI was asked to do
Write a per-account attempt limit for POST /login. After 5 failed attempts on an account within 15 minutes, every attempt on that account must get 429 until 15 minutes have passed since the first failed attempt, even with the right password. A successful login resets the count, and a wrong password gets 401. Email letter case does not matter, and users.findUser also ignores it. The service runs as a single Node.js process, and a separate job cleans up old entries. Hiding which emails are registered is out of scope for this task.
Handoff note
What the AI wrote when it handed over the code:
“A per-account limit against brute-force attempts is in place: after 5 failed attempts in 15 minutes the answer is 429. Each attempt is counted before anything is awaited, so requests that arrive together cannot get past the limit either.”
The code the AI wrote
JavaScript42 linesMark the lines you think are wrong; give each mark a severity and a one-sentence reason.
Click or tap a line. With a keyboard, move to the line and press Enter.
How to pick a severity
- CriticalData loss, a security hole or lost money
- MajorThe user gets a wrong result in a case the task asks for
- MinorA rare edge case outside the task, or readability
In the Testing topic, severity is set by the effect of the broken implementation the test doesn't catch.
Your reason isn't graded; you'll compare it with a sample answer.
const MAX_FAILED_ATTEMPTS = 5;const WINDOW_MS = 15 * 60 * 1000;/** * @typedef {{ id: string, passwordHash: string }} User * @typedef {{ count: number, since: number }} Attempts *//** * POST /login with a limit on failed attempts per account. * * @param {{ findUser(email: string): Promise<User | null> }} users * Finds a user by email, ignoring letter case. * @param {(password: string, hash: string) => Promise<boolean>} verify * @param {Map<string, Attempts>} attempts Shared by all requests. */export function createLogin(users, verify, attempts) { /** * @param {string} email * @param {string} password * @param {number} now Milliseconds since the epoch. */ return async function login(email, password, now) { const key = email.trim(); let entry = attempts.get(key); if (entry === undefined || now - entry.since >= WINDOW_MS) { entry = { count: 0, since: now }; attempts.set(key, entry); } entry.count += 1; const user = await users.findUser(key); if (user !== null && (await verify(password, user.passwordHash))) { attempts.delete(key); return { status: 200, userId: user.id }; } if (entry.count > MAX_FAILED_ATTEMPTS) { return { status: 429 }; } return { status: 401 }; };}The test that proves it
The right option is the test that fails on this code, which means it exposes the bug.
Submitting sends seven pieces of information to a counter; your reason text stays on your device. Data