Description
while reading through the analytics export route i noticed the teacher-role check only fires when the caller passes ?classroomId=. omit the param and the handler falls through to a "global" branch that unions every classroom the caller is merely a member of (student memberships included) and hands back the full teacher-only CSV.
The Problem
src/app/api/analytics/export/route.ts lines 36-63:
if (classroomId) {
// Verify the user is a member of this classroom
await requireTeacher(email, classroomId);
classroomFilter = eq(doubtsTable.classroomId, classroomId);
} else {
// Get all classrooms user is a member of
const userMemberships = await db
.select({ classroomId: membershipsTable.classroomId })
.from(membershipsTable)
.where(eq(membershipsTable.userEmail, email));
const userClassroomIds = userMemberships.map((m: any) => m.classroomId);
...
classroomFilter = inArray(doubtsTable.classroomId, userClassroomIds);
}
no role gate on the else branch. membershipsTable holds every classroom the user is in regardless of role, so a student's memberships get folded straight into the inArray filter that drives the entire CSV.
concrete failure: student joins any classroom (student-role row in membershipsTable). they hit GET /api/analytics/export with no query string. the handler returns a text/csv attachment aggregating every classroom they belong to: trendingDoubts, mostAskedTopics, solvedStats, top-contributor identifiers, weak-topic severity, recent AI pedagogy-drift replies — the exact set the ?classroomId= branch protects with requireTeacher. this is a horizontal privilege escalation — no bypass tooling, no forged token, just skip the query param.
the sibling /api/analytics (without /export) is intentionally student-visible so students can see their own progress. it's specifically the /export route that needs a stricter gate.
Proposed Fix
- filter
userMemberships in the else branch to teacher/owner/admin roles before building the global filter. reuse the TEACHER_ROLES set from src/lib/auth/membership-guard.ts in the where clause so it stays in sync with requireTeacher
- return 403 (or empty JSON) when the caller has no teacher-role memberships, so students hitting the endpoint get a clean deny instead of an accidental all-my-classrooms dump
I would like to work on this under GSSoC 26. please assign!
for labels i think gssoc + type:security + level:critical fits here.
Description
while reading through the analytics export route i noticed the teacher-role check only fires when the caller passes
?classroomId=. omit the param and the handler falls through to a "global" branch that unions every classroom the caller is merely a member of (student memberships included) and hands back the full teacher-only CSV.The Problem
src/app/api/analytics/export/route.tslines 36-63:no role gate on the
elsebranch.membershipsTableholds every classroom the user is in regardless of role, so a student's memberships get folded straight into theinArrayfilter that drives the entire CSV.concrete failure: student joins any classroom (student-role row in
membershipsTable). they hitGET /api/analytics/exportwith no query string. the handler returns atext/csvattachment aggregating every classroom they belong to:trendingDoubts,mostAskedTopics,solvedStats, top-contributor identifiers, weak-topic severity, recent AI pedagogy-drift replies — the exact set the?classroomId=branch protects withrequireTeacher. this is a horizontal privilege escalation — no bypass tooling, no forged token, just skip the query param.the sibling
/api/analytics(without/export) is intentionally student-visible so students can see their own progress. it's specifically the/exportroute that needs a stricter gate.Proposed Fix
userMembershipsin theelsebranch to teacher/owner/admin roles before building the global filter. reuse theTEACHER_ROLESset fromsrc/lib/auth/membership-guard.tsin thewhereclause so it stays in sync withrequireTeacherI would like to work on this under GSSoC 26. please assign!
for labels i think gssoc + type:security + level:critical fits here.