feat(security): add CSRF double-submit protection and login rate limiting
Non-HttpOnly rn_csrf_s/rn_csrf_i cookies, set on login, echoed back as X-CSRF-Token on every state-changing request — defense in depth on top of (not instead of) SameSite=Lax. In-memory sliding-window rate limiting on instructor login (8 attempts / 5 min per ip:email, no new dependency). Real bug found while wiring this up: the Gateway's own progress-relay call (triggered when a student solves a scenario) is a server-to-server POST to /api/progress/record relaying the browser's session — it needs to forward the CSRF token too, and needs to forward BOTH the rn_csrf_s cookie and a matching X-CSRF-Token header (forwarding only the header still 403'd, since the double-submit check needs both on the request Platform API actually receives). Also tightens the Gateway's session-resolution cache from 5000ms to 2000ms, so a revoked/expired session stops working at the Gateway within a tighter, tested bound. Frontend (admin + student API clients) now reads the relevant rn_csrf_* cookie via document.cookie and attaches it on every POST/PATCH/DELETE.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
// 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 ─────────────────────────────────────────────────────────────────
|
||||
// In-memory sliding window, keyed by "ip:identifier" (e.g. ip:email). Fine for a single-process
|
||||
// Platform API — a shared store (Postgres/Redis) would be needed for multi-instance, not a
|
||||
// concern at this scale (see docs/PHASE3A... known limitations).
|
||||
const attempts = new Map();
|
||||
|
||||
function tooManyAttempts(key, max, windowMs) {
|
||||
const now = Date.now();
|
||||
const arr = (attempts.get(key) || []).filter((t) => now - t < windowMs);
|
||||
arr.push(now);
|
||||
attempts.set(key, arr);
|
||||
return arr.length > max;
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - 60 * 60 * 1000;
|
||||
for (const [key, arr] of attempts) {
|
||||
const kept = arr.filter((t) => t > cutoff);
|
||||
if (kept.length) attempts.set(key, kept); else attempts.delete(key);
|
||||
}
|
||||
}, 15 * 60 * 1000).unref();
|
||||
|
||||
module.exports = { makeCsrfToken, parseCookies, checkCsrf, tooManyAttempts };
|
||||
Reference in New Issue
Block a user