// CSRF (double-submit cookie) and login rate limiting. No new dependency — both are small // enough to hand-roll, and SameSite=Lax cookies already do most of the CSRF work; this is // defense in depth on top of that, not a replacement for it. // // CSRF: on login, a non-HttpOnly token is set as a cookie (rn_csrf_s for students, rn_csrf_i // for instructors) — non-HttpOnly *on purpose*, so our own frontend JS can read it via // document.cookie and echo it back as a header. A cross-origin attacker's page can trigger a // cookie-carrying request but cannot read the cookie's value (browser same-origin policy) or // add a custom header to it without CORS, which we don't grant to other origins — so it can // never produce a request where the header matches the cookie. const crypto = require('crypto'); function makeCsrfToken() { return crypto.randomBytes(24).toString('base64url'); } function parseCookies(req) { const header = req.headers.cookie; if (!header) return {}; return Object.fromEntries( header.split(';').map((p) => p.trim().split('=')).filter((p) => p.length === 2).map(([k, v]) => [k, decodeURIComponent(v)]) ); } // Verifies X-CSRF-Token header against the given cookie for any state-changing method. // GET/HEAD/OPTIONS are read-only and exempt (nothing to protect). function checkCsrf(req, cookieName) { if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return true; const cookieValue = parseCookies(req)[cookieName]; const header = req.headers['x-csrf-token']; return !!cookieValue && !!header && cookieValue === header; } // ── Login rate limiting ───────────────────────────────────────────────────────────────── // Phase 4.1 §15: Postgres-backed (migrations/016_login_attempts.sql), not in-memory — the // original in-memory sliding-window Map reset on every Platform API restart (an attacker could // just wait for a deploy) and would silently stop being shared the moment there's more than // one Platform API instance. This table is the fix for both, at essentially no cost: login is // not a hot path, and the query below is a single indexed range scan. // // Deliberately still just a table + two queries, not a new rate-limiting service or Redis — // the volume here (failed login attempts) never justifies that, and a Postgres row is already // exactly as "shared/persistent" as this needs to be. async function tooManyAttempts(pool, key, max, windowMs) { // Insert THEN count (including the just-inserted row), matching the original in-memory // version's semantics exactly: this call itself counts as one of the attempts. await pool.query(`INSERT INTO login_attempts (rate_key) VALUES ($1)`, [key]); const { rows } = await pool.query( `SELECT count(*)::int AS n FROM login_attempts WHERE rate_key = $1 AND created_at > now() - ($2 || ' milliseconds')::interval`, [key, windowMs] ); return rows[0].n > max; } // Periodic cleanup so the table doesn't grow forever — cosmetic/storage only, mirrors // sessions.js's own sweeper pattern rather than inventing a new one. function startLoginAttemptSweeper(pool) { setInterval(() => { pool.query(`DELETE FROM login_attempts WHERE created_at < now() - interval '24 hours'`).catch((e) => { console.error('[login attempt sweep] failed:', e.message); }); }, 60 * 60 * 1000).unref(); } module.exports = { makeCsrfToken, parseCookies, checkCsrf, tooManyAttempts, startLoginAttemptSweeper };