Skip to content

fix(security): stop serving course videos to anyone who asks - #81

Merged
udaycodespace merged 4 commits into
udaycodespace:mainfrom
MOHITKOURAV01:fix/76-video-access-control
Aug 28, 2026
Merged

fix(security): stop serving course videos to anyone who asks#81
udaycodespace merged 4 commits into
udaycodespace:mainfrom
MOHITKOURAV01:fix/76-video-access-control

Conversation

@MOHITKOURAV01

Copy link
Copy Markdown
Contributor

Summary

Course videos were downloadable with no account at all. Two things combined:

app.js served the upload directory to the world
app.use("/uploads", express.static(uploadsDir)), no auth, no enrolment check.

And the public catalogue published the filenames. GET /api/user/getallcourses has no authMiddleware on it (a visitor is meant to
browse) and courseListingController returned whole course documents with
.lean(). sections is part of that document and every section carries
S_content.path.

So one anonymous request listed the storage path of every video in the system,
and a second downloaded it:

curl -s 'localhost:5000/api/user/getallcourses?limit=100' \
  | python3 -c 'import json,sys; [print(s["S_content"]["path"]) for c in json.load(sys.stdin)["data"] for s in (c.get("sections") or []) if s.get("S_content")]'

curl -O 'localhost:5000/uploads/S_content-1742555168462-781033667.mp4'

Fixing only the listing would not have been enough — the directory would still
be readable by anyone who had ever legitimately seen a path, including a
student who enrolled once, and anyone they forwarded the URL to.

Related Issue

Closes #76

What changed

  • The catalogue drops sections and reports a sectionCount instead. A card
    renders nothing from inside a section, so the paths were never needed there.
  • /uploads is no longer served. Section videos come from
    GET /api/user/coursevideo/:courseid/:sectionIndex.
  • A <video> element cannot send an Authorization header, so that route
    takes its credential from the query string — but not the session token,
    which is good for a day against every endpoint and would end up in browser
    history, Referer headers and access logs. utils/playbackTokens.js mints a
    30-minute token scoped to one course and to reading video, issued by
    /api/user/coursecontent/:courseid, which is the only place that has already
    checked enrolment.
  • The stored path goes through resolveSafeUploadPath (already in the tree
    from [Security]: Restrict teacher course deletion to owned courses #40), so a traversal that ever reached the database cannot turn this
    route into an arbitrary file read.
  • Range requests are handled. express.static was doing that for free, and
    seeking is unusable without it — a player also reads the MP4 index with a
    suffix range (bytes=-200) before it can start. 206 with Content-Range,
    416 past the end of the file.
  • Responses are Cache-Control: private, no-store, since the URL that produced
    them carries a credential.

Type

  • Bug fix
  • New feature
  • Refactor
  • Docs only
  • Tests
  • Config / workflow
  • Security
  • Breaking change

Areas touched

  • Frontend
  • Backend
  • Database
  • Docs
  • Workflow / GitHub Actions
  • Config / environment

Testing

  • Tested locally
  • Build passes
  • Lint passes
  • Tests added or updated
  • Docs only, no runtime testing needed

npm test in backend/: 150 passing (128 before, 22 added). npm run build
in frontend/ passes.

Test steps

# The catalogue no longer contains a single file path.
curl -s 'localhost:5000/api/user/getallcourses' | grep -c 'S_content'   # 0

# The directory is not served.
curl -sI localhost:5000/uploads/intro.mp4 | head -1                     # 404

# The stream route, no token.
curl -sI 'localhost:5000/api/user/coursevideo/<id>/0' | head -1          # 401

# With a session token in place of a playback token.
curl -sI "localhost:5000/api/user/coursevideo/<id>/0?token=$SESSION"     # 401

# As an enrolled student: /coursecontent returns playbackToken.
curl -sI "localhost:5000/api/user/coursevideo/<id>/0?token=$PLAYBACK"    # 200
curl -sI -H 'Range: bytes=200-399' "...?token=$PLAYBACK" | head -1       # 206

Then open a course you are enrolled in and scrub the timeline — seeking still
works.

Screenshots

  • Not needed
  • Added below

Edge cases checked

  • Empty or missing data
  • Loading / slow response
  • API failure / server error
  • Rate limit / throttling
  • Invalid or unexpected input
  • Permission / access denied
  • Partial or inconsistent data
  • Mobile / small screen behavior
  • Other

Other edge case details

  • A missing token, an expired one, one signed for a different course, and a
    session token all get the same 401 — distinguishing them would tell a caller
    which part of the guess was right.
  • A row whose file has been removed from disk 404s rather than 500s.
  • sections is declared as {} on the schema, so a course where it is not an
    array reports sectionCount: 0 rather than throwing.
  • Rows written before the upload handler stored a filename only have path;
    both shapes are read.
  • seed.js writes paths for files that do not exist. Those sections now 404
    from the stream route instead of from the static handler — same outcome,
    different place.

Checklist

  • Read CONTRIBUTING.md
  • Linked the issue
  • Assigned before starting or approved by maintainer
  • Changes are focused on one issue
  • No debug logs or unused code
  • Documentation updated if needed
  • No new warnings or console errors
  • Changes are meaningful, not trivial

Notes

The load-bearing line is the scope check in verifyPlaybackToken. Both
tokens are signed with JWT_SECRET, so without it any session token would open
this route for every course — which is most of what the fix is for. There is a
test that fails if it is removed.

The admin course listing is untouched: it is behind requireAdmin, and the
admin dashboard is the one place a full document is legitimate.

One consequence worth flagging: /coursecontent now returns each section with
a streamUrl instead of S_content.path/filename. CourseContent.jsx is
the only consumer and is updated in this PR, but any external client reading
that field will need to follow.

docs/issue-76-video-access-control.md has the reasoning, including why
fixing the listing alone was not enough.

/uploads was mounted with express.static and no auth, and the public
catalogue endpoint returned whole course documents — including every
section's S_content.path. One anonymous request listed the storage path of
every video in the system and a second downloaded it, so enrolment and
payment could both be skipped entirely.

Fixing only the listing would not have been enough: the directory would still
have been readable by anyone who had ever seen a path.

- The catalogue drops `sections` and reports a sectionCount instead. A card
  renders nothing from inside a section, so the paths were never needed there.
- /uploads is no longer served. Section videos come from
  GET /api/user/coursevideo/:courseid/:sectionIndex.
- A <video> element cannot send an Authorization header, so that route takes
  its credential from the query string — but not the session token, which is
  good for a day against every endpoint and would end up in history, Referer
  headers and access logs. playbackTokens.js mints a 30-minute token scoped to
  one course and to reading video, issued by /coursecontent, which is the only
  place that has already checked enrolment. The scope check is what stops a
  session token from being accepted here.
- The stored path is resolved through resolveSafeUploadPath, so a traversal
  that ever reached the database cannot become an arbitrary file read.
- Range requests are handled, since express.static was doing that for free and
  seeking is unusable without it. 206 with Content-Range, 416 past the end,
  and the suffix form a player uses to read the MP4 index.

Responses are private, no-store, because the URL that produced them carries a
credential.

Closes udaycodespace#76
@MOHITKOURAV01

Copy link
Copy Markdown
Contributor Author

Heads up on merge order: this branch and #78 both add to the import block in backend/routers/userRoutes.js and backend/controllers/userControllers.js#78 wires up resendOtpController, this one wires up courseVideoController. Adjacent lines, so they conflicted.

I rebased this branch on top of #78 (which itself sits on #77) and resolved it. Both conflicts were pure import additions, so the resolution keeps both sides; no logic was dropped from either PR.

So this PR currently shows #77 and #78's commits in its diff. Merge those two first and this collapses to just its own changes.

Verified: merging #77#78#79#80#81 in sequence is conflict-free, and the combined result passes 190 backend tests and 22 frontend tests with a clean vite build.

#79 and #80 touch nothing these three touch and can merge in any order.

@udaycodespace
udaycodespace self-requested a review August 17, 2026 09:47
@udaycodespace udaycodespace added ECSoC26 Required label for a PR to be eligible for Sentinel scoring in review PR is up and waiting on maintainer review labels Aug 17, 2026
@udaycodespace

Copy link
Copy Markdown
Owner

@MOHITKOURAV01 The PR looks good overall.

  1. Please resolve the merge conflict in backend/controllers/userControllers.js and update the branch.
  2. Once the conflict is resolved, I’ll proceed with the review.

@udaycodespace udaycodespace added redo Reviewed — needs changes before it can be merged and removed in review PR is up and waiting on maintainer review frontend documentation backend configuration fullstack database tests labels Aug 18, 2026
@udaycodespace

Copy link
Copy Markdown
Owner

@MOHITKOURAV01 I followed the lower-risk PR review and merge order first, which led to these conflicts. Please fix the conflicts and rebase the active PRs accordingly across #68, #77, #78, and #81

The same three conflicts as fix/73-otp-resend, resolved the same way:

- userControllers.js require block: the paymentDetails require this branch
  carried has moved into enrollmentController (udaycodespace#62) and nothing here uses it,
  so only accountIdentity is kept.
- Register.jsx imports: main's ROLES/roleLabel (udaycodespace#84) alongside this branch's
  Toast and VerifyEmailPanel.
- Register.jsx handleSelect: main's implementation, with this branch's toast
  helpers and pending-email effect in front of it.
@MOHITKOURAV01

Copy link
Copy Markdown
Contributor Author

Overlap with #98, worth knowing about

Both merge cleanly into main, but they conflict with each other on backend/controllers/userControllers.js, backend/routers/userRoutes.js and frontend/src/components/user/student/CourseContent.jsx — and the last one is substantive:

They pull in the same direction. If #98 lands first the resolution gets easier, not harder: it adds readVideoPath in frontend/src/lib/courseProgress.js, which is the single point where a stored S_content becomes a URL — exactly the hook this PR needs, instead of the inline path juggling that currently sits in the middle of a JSX onClick.

Whichever you take second, tell me and I will push the resolution.

The same four conflicts as the resend branch this one is stacked on, from udaycodespace#63's
credential throttling and udaycodespace#72's email normalisation landing on main.

registerController: this branch's version supersedes main's on both hunks — the
verified/unverified split replaces the unconditional "User already exists", and
issueVerificationOtp saves and mails in one helper, already carrying main's
isDuplicateOn catch in the right place.

userRoutes: main's rate limiter and failure throttle are kept on verify-otp,
forgot-password and reset-password, and /resend-otp is given a rate limiter for
the same reason /forgot-password has one and no failure throttle for the same
reason it has none. The video routes this branch adds are untouched.
@udaycodespace

Copy link
Copy Markdown
Owner

Overlap with #98, worth knowing about

Both merge cleanly into main, but they conflict with each other on backend/controllers/userControllers.js, backend/routers/userRoutes.js and frontend/src/components/user/student/CourseContent.jsx — and the last one is substantive:

They pull in the same direction. If #98 lands first the resolution gets easier, not harder: it adds readVideoPath in frontend/src/lib/courseProgress.js, which is the single point where a stored S_content becomes a URL — exactly the hook this PR needs, instead of the inline path juggling that currently sits in the middle of a JSX onClick.

Whichever you take second, tell me and I will push the resolution.

Hey @MOHITKOURAV01 , no worries. The conflicts are happening because I’m reviewing PRs based on LOC, starting with the smaller ones instead of following PR order.

For now, please pause raising new PRs and just resolve the conflicts in the existing ones. Once those are cleared, we can move to the next PRs. This will make the review flow easier.

GitHub reported this branch as conflicting; the merge resolves textually
with no conflicted hunks. Everything main landed while this was open —
the payments and bookmarks aggregations, the navbar panel links, the
upload validation — sits beside this change rather than on top of it.

Backend 435 pass, frontend 169, build clean.

One note: the first full backend run reported a single failure in
payment-listing ("the endpoint returns one page and the true total").
That suite passes in isolation, and three consecutive full runs are
clean, so it is a timing artefact of the shared mongodb-memory-server
under load rather than anything this merge did. Flagging it rather than
quietly re-running until green.
@udaycodespace

Copy link
Copy Markdown
Owner

@MOHITKOURAV01 Conflicts resolved! Ready to merge!

@udaycodespace
udaycodespace merged commit d64f164 into udaycodespace:main Aug 28, 2026
1 check passed
@ecsoc-sentinel ecsoc-sentinel Bot added the ECSoC26-L3 Difficult, auto-assigned by Sentinel — 15 points label Aug 28, 2026
@udaycodespace udaycodespace added good-backend PA-awarded bonus for outstanding backend work — +50 XP and removed redo Reviewed — needs changes before it can be merged labels Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ECSoC26-L3 Difficult, auto-assigned by Sentinel — 15 points ECSoC26 Required label for a PR to be eligible for Sentinel scoring good-backend PA-awarded bonus for outstanding backend work — +50 XP

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Security]: Course videos are downloadable without an account — /uploads is public and the catalogue leaks every file path

2 participants