Area: mobieusLearn (audit p8) · Surface: POST /learn/scorm/launch/{eid}/{aid}/start, /learn/scorm/launch/{attemptId}/next, /prev, /learn/api/scorm/{attemptId}/set-value, /commit, /terminate · Dimension: security · Severity: minor
Every other state-changing Learn POST (cohorts, paths, live-sessions, AI endpoints) calls $this->validateCsrf() inline because the public group provides no CSRF middleware, but the SCORM runtime controller omits it entirely. Exploitability is limited: the API handlers read the body from php://input as JSON (json() at line 259), so a classic auto-submitting HTML form (application/x-www-form-urlencoded) decodes to [] and the element is empty; and ScormRuntimeService::assertAttemptOwner (ScormRuntimeService.php:335-340) blocks cross-user writes. The realistic CSRF impact is forcing a victim's own attempt to be marked complete/terminated via the next/prev/terminate endpoints. Still a genuine missing-control gap versus the documented convention (CLAUDE.md: 'call validateCsrf() at start of every POST handler').
Evidence
These routes are registered in the public group at platform/src/routes.php:340 whose middleware list contains NO CsrfValidationMiddleware and NO AuthMiddleware (confirmed: PerformanceMiddleware, ProbeDetectionMiddleware, IpBanMiddleware, LlmBotBlockMiddleware, MaintenanceMiddleware, AccessModeMiddleware, LurkerTrackerMiddleware, SuspensionGateMiddleware, GateMiddleware). The handlers in platform/src/Controllers/Learn/LearnScormRuntimeController.php contain zero validateCsrf() calls (`grep -c validateCsrf` = 0). `start()` (line 34) only calls `$this->requireLearner()`; the API handlers (apiSetValue line 188, apiCommit 203, apiTerminate 210) call only `$this->jsonOnly()` which just sets a Content-Type header. These mutate state: start() find-or-creates an attempt; next/prev mark the attempt complete (line 82-85 `UPDATE learn_attempts SET status="complete"...`); set-value/commit/terminate write CMI data and finalize attempts.
Suggested fix. Add $this->validateCsrf() (or move these routes into a group that includes CsrfValidationMiddleware) to start(), nextSco(), prevSco(), apiSetValue(), apiCommit(), apiTerminate(). For the JSON API endpoints the front-end already sends X-CSRF-TOKEN via the app.min.js fetch wrapper, so adding the check is non-breaking; the SCO iframe submitting cross-origin would need the token threaded through, matching how cmi5 start() already does requireAuth()+validateCsrf().
Filed by the automated tenant-app audit and adversarially evidence-verified. Status: verified. Open — not yet actioned.
Patrick Bass
@mobieus