Disclosure · Access control on a course platform
Paid courses within anyone's reach
The client was a platform with a simple business model: pay the subscription, get the courses. In a review I found the paywall existed in the UI, but not on the server. This is the bug class that shows up in almost every content platform: the page is protected, not the data.
1. A subdomain serving the entire content for free
There was a legacy subdomain running an LMS (TutorLMS/WordPress) hosting the
courses. Anyone could watch the full lessons of both courses
without paying or creating an account. Worse: the list of all lessons
was published by WordPress itself in a sitemap file
(wp-sitemap-posts-lesson-1.xml), so there wasn't even anything to
guess.
If the subdomain is unused (it hadn't been updated in a year and held only two courses), the cleanest move is to delete it. If kept, enforce enrollment verification before showing each lesson.
2. The API that returned all the content with no session
On the main platform, the endpoint building a course page returned all the content —video URLs and PDFs— without asking for login, even for courses flagged "subscribers only":
curl "https://<platform>/api/cursos/course-by-id/<course-id>"
# -> 200 OK
# { "title": "...", "isSubscribersOnly": true,
# "lessons": [ { "videoUrl": "...", "pdfUrl": "..." }, ... ] }
# (no cookies, no token, no subscription)
The fix is to require a session and check enrollment before serializing
videoUrl/pdfUrl; with no access, return only public
info (title, image, price). Ideally the asset URL is never sent: resolve the
video server-side after checking the permission.
3. Endpoints accepting foreign IDs with no login
The course-progress endpoints accepted any course and user ID without requiring authentication, and the queries reached the real database (behavior changed depending on whether the user existed):
# No login, attacker-controlled IDs:
GET /api/cursos/course-progress?courseId=<...>&user=<...>
GET /api/cursos/course-progress/suscriptions?courseId=<...>&user=<...>
4. The protection that lived only in the UI
On the Leaderboard, names appeared blurred by CSS, suggesting they were protected. But the full name was still in the HTML, and the leaderboard endpoint returned the Top 100 with unmasked names:
curl "https://<platform>/api/leaderboard?limit=100"
# -> 200 with the full names of the top 100
Blurring on the client is decoration. If the data must not be seen, it must not be sent.
5. The checkout price was set by the URL
In the subscription checkout, the amount came from a URL parameter. Opening the
yearly plan with ?type=premium dropped it to a fraction of the
price; with ?type=plus, to another fraction of basic.
GET /api/mercadopago/bricks-subscription/quote?suscriptionPlanId=<yearly-plan>&tier=premium
# -> an amount far below the plan's real price
I tested it with my own account: the processor charged the manipulated amount. The price of a transaction must never come from a client-supplied value: derive it server-side from the plan.
6. Access email lists in the public JavaScript
Two lists were hardcoded in the public bundle: free access to a new product and the admin list. The first granted access with no subscription to whoever controlled one of those mailboxes; the second granted the entire panel.
- New product: just registering and verifying an email from the list (even one that didn't exist before) made the server issue an SSO and grant free access to the paid product.
- Admin panel: the admin email list was also in the bundle, with personal addresses (gmail/hotmail/live), no corporate SSO or MFA. Whoever controls or registers one of those mailboxes gets the panel.
- And one more: the endpoint forwarding events to Meta's Conversions API accepted events with no login or signature, including amounts and third-party data, and Meta accepted them. A competitor can poison your measurement and ad spend.
Bonus: the errors said too much
With an invalid ID on course-progress, suscriptions or
course-by-id, the server returned the full technical error, with the
database engine and internal table details. Not serious on its own, but it's a
free map for anyone hunting more flaws: errors should be generic.
The lesson
Content is protected on the server, not on the client. A session to know who you are, a permission check to know what you can see, and the price always computed server-side.