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:
@@ -1,14 +1,21 @@
|
||||
// Platform Shell API client. Phase 2B: identity is a server-controlled session cookie
|
||||
// (rn_session, HttpOnly — this code can't read it, and doesn't need to; the browser sends it
|
||||
// automatically on every same-origin request). This replaces Phase 2A's localStorage
|
||||
// student_id + X-Student-Id header, which was explicitly pilot-only and client-forgeable.
|
||||
// See docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3.
|
||||
// Platform Shell API client. Identity is a server-controlled session cookie (rn_session,
|
||||
// HttpOnly — this code can't read it, and doesn't need to; the browser sends it automatically
|
||||
// on every same-origin request). Roster name-picking is classroom/pilot identification, not
|
||||
// production authentication (see docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §5) — what's
|
||||
// hardened here is the session that results from it: persistent (survives an API restart),
|
||||
// unforgeable, and checked on every request.
|
||||
//
|
||||
// display_name is still cached in sessionStorage purely for UI convenience (showing "Hi,
|
||||
// Alice" without an extra round trip) — it carries no authority; every actual API call is
|
||||
// authorized by the cookie alone, not by anything read from here.
|
||||
//
|
||||
// rn_csrf_s is a deliberately non-HttpOnly cookie (double-submit CSRF pattern) — this code
|
||||
// reads it and echoes it back as a header on every state-changing request, proving the request
|
||||
// came from our own origin's JS (a cross-origin attacker can trigger the cookie but can't read
|
||||
// its value or attach a matching custom header without CORS, which we don't grant elsewhere).
|
||||
|
||||
const DISPLAY_CACHE_KEY = 'rootnode_student_display'
|
||||
const CSRF_COOKIE = 'rn_csrf_s'
|
||||
|
||||
export function getCachedDisplay() {
|
||||
try { return JSON.parse(sessionStorage.getItem(DISPLAY_CACHE_KEY) || 'null') } catch { return null }
|
||||
@@ -20,6 +27,10 @@ function clearCachedDisplay() {
|
||||
sessionStorage.removeItem(DISPLAY_CACHE_KEY)
|
||||
}
|
||||
|
||||
function getCookie(name) {
|
||||
return document.cookie.split('; ').find((c) => c.startsWith(`${name}=`))?.split('=')[1]
|
||||
}
|
||||
|
||||
async function json(res) {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
@@ -28,10 +39,20 @@ async function json(res) {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function mutate(path, body) {
|
||||
return fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCookie(CSRF_COOKIE) || '' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}).then(json)
|
||||
}
|
||||
|
||||
export const api = {
|
||||
cohorts: () => fetch('/api/roster/cohorts').then(json),
|
||||
roster: (cohort) => fetch(`/api/roster?cohort=${encodeURIComponent(cohort)}`).then(json),
|
||||
login: async (student_id) => {
|
||||
// No CSRF token yet at login (nothing authenticated to forge into) — same reasoning as the
|
||||
// instructor login route.
|
||||
const student = await fetch('/api/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ student_id }),
|
||||
}).then(json)
|
||||
@@ -39,7 +60,7 @@ export const api = {
|
||||
return student
|
||||
},
|
||||
logout: async () => {
|
||||
await fetch('/api/logout', { method: 'POST' }).catch(() => {})
|
||||
await mutate('/api/logout').catch(() => {})
|
||||
clearCachedDisplay()
|
||||
},
|
||||
whoami: () => fetch('/api/session/whoami').then(json),
|
||||
@@ -47,6 +68,6 @@ export const api = {
|
||||
curriculumPath: () => fetch('/api/curriculum/path').then(json),
|
||||
|
||||
environmentStatus: () => fetch('/api/environment/status').then(json),
|
||||
launchEnvironment: () => fetch('/api/environment/launch', { method: 'POST' }).then(json),
|
||||
resetEnvironment: () => fetch('/api/environment/reset', { method: 'POST' }).then(json),
|
||||
launchEnvironment: () => mutate('/api/environment/launch'),
|
||||
resetEnvironment: () => mutate('/api/environment/reset'),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user