You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A whole-repo security review (2026-07-24) found one systemic vulnerability class: broken access control (BOLA/IDOR). API endpoints authenticate the caller — or skip auth entirely — but never verify that the requested object belongs to that caller. Passing another user's ID in the path, query, or body returns or mutates their data.
Impact is amplified because tutor and parent roles are self-registerable via the public /auth/register (_ALLOWED_REGISTER_ROLES = {"student","tutor","parent"}), so the role gates guarding several endpoints are not a real trust boundary.
Clean dimensions (no findings): SQL/command injection, unsafe deserialization, path traversal, SSRF, frontend XSS, committed secrets, JWT crypto (HS256 pinned, no default secret), CORS (prod-pinned), CI workflows.
Review method: Fable orchestrated 5 parallel finder subagents by dimension → independent per-finding verification subagents → kept confidence ≥ 8 as confirmed.
Confirmed findings (confidence ≥ 8)
#
Endpoint(s)
File
Problem
Severity
1
GET /api/v1/summaries/{user_id}
src/api/handlers/summaries.py:104
authenticated, no ownership check
High
2
POST /practice/assign/complete/summary
src/api/handlers/practice.py:112,426,539
unauthenticated (optional auth, result unused)
High
3
GET /nudges/users/{user_id}POST /nudges/check/{id}/engage
src/api/handlers/nudges.py:37,126,298
unauthenticated
High
4
GET /enhancements/qa/conversation-context/{student_id}
src/api/handlers/enhancements.py:82
self-check present on sibling route, omitted here
High
5
GET /messaging/threads?user_id=
src/api/handlers/messaging.py:224
caller-supplied user_id overrides JWT identity
High
6
GET /dashboards/parent/student/{id}/parent/students
src/api/handlers/dashboards.py:29
self-registerable parent role, no relationship check, returns all students
High
7
POST /overrides/
src/api/handlers/overrides.py:36
trusts body tutor_id/student_id/target_id → forge audit trail + mutate content
High
8
GET /overrides/{student_id}
src/api/handlers/overrides.py:147
no tutor↔student relationship check
Medium
Related — confirmed real, lower confidence (fix in same sweep)
9. jobs.py GET /jobs/{job_id} + WS /jobs/{job_id}/ws — returns/streams another student's generated practice result; WS has no auth dependency at all. Bounded by unguessable job UUIDv4 (leak-via-share, not enumeration).
10. advanced_analytics.pyGET /analytics/advanced/engagement/{user_id} — any tutor/parent reads any student's 30-day activity profile. No direct PII, so bounded.
Root causes
No shared object-level authorization primitive — handlers do authentication + coarse role, but nothing asserts "caller owns/relates to this object." Correct examples already exist (goals.py, progress.py, messaging.get_thread, enhancements.get_conversation_history).
get_current_user_optional used where auth should be mandatory (practice, nudges, jobs), then never read.
Identity trusted from request body/query instead of the JWT (overrides.tutor_id, messaging ?user_id).
TutorStudentAssignment (src/models/tutor_student.py) exists but is never queried; no parent↔student model exists at all.
Over-permissive self-registration of tutor/parent.
Order:#60 → #61 → (#62, #63, #64 in parallel) → #65. Phase 1 (#61) is the shared foundation; Phases 2–4 can run in parallel across sessions once it lands.
Summary
A whole-repo security review (2026-07-24) found one systemic vulnerability class: broken access control (BOLA/IDOR). API endpoints authenticate the caller — or skip auth entirely — but never verify that the requested object belongs to that caller. Passing another user's ID in the path, query, or body returns or mutates their data.
Impact is amplified because
tutorandparentroles are self-registerable via the public/auth/register(_ALLOWED_REGISTER_ROLES = {"student","tutor","parent"}), so the role gates guarding several endpoints are not a real trust boundary.Clean dimensions (no findings): SQL/command injection, unsafe deserialization, path traversal, SSRF, frontend XSS, committed secrets, JWT crypto (HS256 pinned, no default secret), CORS (prod-pinned), CI workflows.
Confirmed findings (confidence ≥ 8)
GET /api/v1/summaries/{user_id}src/api/handlers/summaries.py:104POST /practice/assign/complete/summarysrc/api/handlers/practice.py:112,426,539GET /nudges/users/{user_id}POST /nudges/check/{id}/engagesrc/api/handlers/nudges.py:37,126,298GET /enhancements/qa/conversation-context/{student_id}src/api/handlers/enhancements.py:82GET /messaging/threads?user_id=src/api/handlers/messaging.py:224user_idoverrides JWT identityGET /dashboards/parent/student/{id}/parent/studentssrc/api/handlers/dashboards.py:29POST /overrides/src/api/handlers/overrides.py:36tutor_id/student_id/target_id→ forge audit trail + mutate contentGET /overrides/{student_id}src/api/handlers/overrides.py:147Related — confirmed real, lower confidence (fix in same sweep)
jobs.pyGET/jobs/{job_id}+ WS/jobs/{job_id}/ws— returns/streams another student's generated practiceresult; WS has no auth dependency at all. Bounded by unguessable job UUIDv4 (leak-via-share, not enumeration).advanced_analytics.pyGET /analytics/advanced/engagement/{user_id}— any tutor/parent reads any student's 30-day activity profile. No direct PII, so bounded.Root causes
goals.py,progress.py,messaging.get_thread,enhancements.get_conversation_history).get_current_user_optionalused where auth should be mandatory (practice, nudges, jobs), then never read.overrides.tutor_id,messaging ?user_id).TutorStudentAssignment(src/models/tutor_student.py) exists but is never queried; no parent↔student model exists at all.tutor/parent.Remediation phases
Order: #60 → #61 → (#62, #63, #64 in parallel) → #65. Phase 1 (#61) is the shared foundation; Phases 2–4 can run in parallel across sessions once it lands.