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:
2026-08-18 13:36:20 -05:00
parent 62726455d5
commit 7ce435ad32
4 changed files with 138 additions and 19 deletions
+57
View File
@@ -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 };
+18 -4
View File
@@ -23,6 +23,7 @@ const envCache = require('./env-cache');
const PORT = Number(process.env.GATEWAY_PORT) || 4200;
const PLATFORM_API_URL = process.env.PLATFORM_API_URL || 'http://localhost:4300';
const STUDENT_COOKIE = 'rn_session';
const STUDENT_CSRF_COOKIE = 'rn_csrf_s'; // Phase 3A — see relayScenarioOutcome for why the Gateway needs this too
// Routing hint only — NOT a security check by itself (ownership is always re-verified via the
// session, see checkOwnership/resolveOwnedRunningEnvironment). Its only job is disambiguating
// "this unprefixed request came from inside the Lab UI" from "this came from the dashboard,"
@@ -30,7 +31,9 @@ const STUDENT_COOKIE = 'rn_session';
// dashboard too, and naively using it hijacked EVERY unmatched dashboard request (including
// GET /api/session/whoami) to the student's Lab container once one existed. Found by testing.
const LAB_ACTIVE_COOKIE = 'rn_lab_active';
const SESSION_CACHE_TTL_MS = 5000;
// Phase 3A: shortened from 5000ms so a revoked/expired session stops working here within a
// tighter, testable bound (see platform/test/e2e-phase3a.js "revoked mid-use" case).
const SESSION_CACHE_TTL_MS = 2000;
const app = express();
// Deliberately no express.json() globally — see the Phase 2A note this carries forward:
@@ -120,15 +123,26 @@ async function relayScenarioOutcome({ env, req, res, scenarioPath, extractOutcom
const passed = extractOutcome(body);
if (passed !== null) {
const validationCount = Array.isArray(body.checks) ? body.checks.length : undefined;
const token = parseCookies(req)[STUDENT_COOKIE];
const cookies = parseCookies(req);
const token = cookies[STUDENT_COOKIE];
const csrf = cookies[STUDENT_CSRF_COOKIE] || '';
// Phase 3A: /api/progress/record is CSRF-protected like every other student mutation. The
// double-submit check needs BOTH the rn_csrf_s cookie AND a matching X-CSRF-Token header on
// the request Platform API actually sees — so both are forwarded here, not just the header
// (an earlier version of this fix sent only the header, which still 403'd).
fetch(`${PLATFORM_API_URL}/api/progress/record`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: `${STUDENT_COOKIE}=${token}` },
headers: {
'Content-Type': 'application/json',
Cookie: `${STUDENT_COOKIE}=${token}; ${STUDENT_CSRF_COOKIE}=${csrf}`,
'X-CSRF-Token': csrf,
},
body: JSON.stringify({
scenario_id: req.params.scenarioId, status: passed ? 'completed' : 'in_progress',
environment_id: env.environment_id, validation_count: validationCount,
}),
}).catch((e) => console.error('[progress relay] failed:', e.message));
}).then((r) => { if (!r.ok) console.error('[progress relay] Platform API rejected the update:', r.status); })
.catch((e) => console.error('[progress relay] failed:', e.message));
}
}