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,7 +1,17 @@
|
||||
// Admin API client. Uses the rn_admin session cookie exclusively (same-origin, sent
|
||||
// Admin API client. Uses the rn_instructor session cookie exclusively (same-origin, sent
|
||||
// automatically) — completely separate credential/cookie from student sessions (see
|
||||
// platform/api/src/sessions.js). No admin data is ever cached client-side beyond the current
|
||||
// React state; nothing here reads or writes localStorage.
|
||||
//
|
||||
// rn_csrf_i is a deliberately non-HttpOnly cookie (double-submit CSRF pattern, mirrored on the
|
||||
// student side in frontend/src/platform/api.js) — read here and echoed back as a header on
|
||||
// every state-changing request.
|
||||
|
||||
const CSRF_COOKIE = 'rn_csrf_i'
|
||||
|
||||
function getCookie(name) {
|
||||
return document.cookie.split('; ').find((c) => c.startsWith(`${name}=`))?.split('=')[1]
|
||||
}
|
||||
|
||||
async function json(res) {
|
||||
if (!res.ok) {
|
||||
@@ -11,23 +21,30 @@ async function json(res) {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function withCsrf(init) {
|
||||
return { ...init, headers: { ...init.headers, 'X-CSRF-Token': getCookie(CSRF_COOKIE) || '' } }
|
||||
}
|
||||
function post(path, body) {
|
||||
return fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }).then(json)
|
||||
return fetch(path, withCsrf({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined })).then(json)
|
||||
}
|
||||
function patch(path, body) {
|
||||
return fetch(path, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(json)
|
||||
return fetch(path, withCsrf({ method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).then(json)
|
||||
}
|
||||
function del(path) {
|
||||
return fetch(path, { method: 'DELETE' }).then(json)
|
||||
return fetch(path, withCsrf({ method: 'DELETE' })).then(json)
|
||||
}
|
||||
function get(path) {
|
||||
return fetch(path).then(json)
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
login: (password) => post('/api/admin/login', { password }),
|
||||
logout: () => post('/api/admin/logout'),
|
||||
whoami: () => get('/api/admin/whoami'),
|
||||
// No CSRF token yet at login (nothing authenticated to forge into).
|
||||
login: (email, password) => fetch('/api/auth/instructor/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }),
|
||||
}).then(json),
|
||||
logout: () => post('/api/auth/logout'),
|
||||
session: () => get('/api/auth/session'),
|
||||
changePassword: (old_password, new_password) => post('/api/auth/change-password', { old_password, new_password }),
|
||||
|
||||
dashboard: () => get('/api/admin/dashboard'),
|
||||
|
||||
@@ -47,10 +64,20 @@ export const adminApi = {
|
||||
cohortStudents: (id) => get(`/api/admin/cohorts/${id}/students`),
|
||||
cohortProgress: (id) => get(`/api/admin/cohorts/${id}/progress`),
|
||||
assignCohortCurriculum: (id, curriculum_path) => post(`/api/admin/cohorts/${id}/curriculum`, { curriculum_path }),
|
||||
assignCohortInstructor: (id, instructor_id, role_in_cohort) => post(`/api/admin/cohorts/${id}/instructors`, { instructor_id, role_in_cohort }),
|
||||
prepareLabs: (id) => post(`/api/admin/cohorts/${id}/prepare-labs`),
|
||||
destroyLabs: (id) => post(`/api/admin/cohorts/${id}/destroy-labs`),
|
||||
|
||||
resetEnvironment: (id) => post(`/api/admin/environments/${id}/reset`),
|
||||
destroyEnvironment: (id) => del(`/api/admin/environments/${id}`),
|
||||
environmentEvents: (id) => get(`/api/admin/environments/${id}/events`),
|
||||
|
||||
instructors: () => get('/api/admin/instructors'),
|
||||
createInstructor: (fields) => post('/api/admin/instructors', fields),
|
||||
instructor: (id) => get(`/api/admin/instructors/${id}`),
|
||||
updateInstructor: (id, fields) => patch(`/api/admin/instructors/${id}`, fields),
|
||||
revokeInstructorSessions: (id) => post(`/api/admin/instructors/${id}/revoke-sessions`),
|
||||
resetInstructorPassword: (id, new_password) => post(`/api/admin/instructors/${id}/reset-password`, { new_password }),
|
||||
|
||||
auditEvents: () => get('/api/admin/audit-events'),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user