Replaces the in-memory sliding-window Map (reset on every restart, not shareable across instances) with a login_attempts table (migration 016). No Redis added - login is not a hot path, and this is a single indexed range-scan query. Restart behavior confirmed for real: platform/test/e2e-phase3a.js's rate-limiting test passes after a genuine Platform API restart in the same run.
67 lines
3.5 KiB
JavaScript
67 lines
3.5 KiB
JavaScript
// 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 };
|