Skip to content

[Security] analytics CSV export bypasses requireTeacher when classroomId is omitted — student can exfiltrate teacher analytics #885

Description

@vedant7007

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.

Metadata

Metadata

Assignees

Labels

backendAPI routes, database, server logicgssoc'26GSSoC program issuelevel:criticalCritical level tasktype:securitySecurity fix

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions