Files
rootnode-academy/platform/api/src/security.js
T
ced4568 addf474b70 feat(security): Postgres-backed login rate limiting
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.
2026-08-24 20:15:03 -05:00

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 };