Disclosure · Access control on a course platform

Paid courses within anyone's reach

Leer en español

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.
← Back to Security