Area: Monetization (audit p10) · Surface: GET /learn/enrol/success (CourseCatalogController@success) · Dimension: security · Severity: major
Anyone who obtains a Stripe Checkout session id (the `cs=cs_live_...` value appears in success-redirect URLs, browser history, Referer headers sent to third-party assets, and proxy/analytics logs) can hit /learn/enrol/success?cs=<id> while logged out and view the buyer's email address. Because the handler also drives enrolment + the confirmation email on the `pending`->`paid` transition (lines 270-298), a third party replaying the URL can also trigger enrolment side-effects for the legitimate buyer. Session ids are long/unguessable so this is not a mass-enumeration hole, but it is a real cross-user information disclosure with no authentication or ownership binding on a monetization surface that handles PII. OWASP A01:2021 Broken Access Control (IDOR).
Evidence
routes.php:713 `$router->get('/learn/enrol/success', 'CourseCatalogController@success');` is inside the public group opened at routes.php:340 (`$router->group('', [PerformanceMiddleware, ..., GateMiddleware], ...)`) — NO AuthMiddleware (verified: zero `});` between line 340 and 713). The handler in CourseCatalogController.php:245-310 does `$sessionId = trim($_GET['cs'] ?? '');` then `$purchase = TenantCoursePurchase::findBySession($sessionId);` (lookup by attacker-supplied session id, no user-ownership check — TenantCoursePurchase.php:23-30 selects purely `WHERE stripe_session_id = :sid`). It then renders `courses/success` which prints the buyer's email: templates/courses/success.php:40 `at <strong><?= $e($purchase['buyer_email'] ?? '') ?></strong>` and :57. There is also no `currentUser()` check anywhere in `success()`.
Suggested fix. In success(), require auth (or at minimum bind the purchase to the session user) and verify `(int)$purchase['buyer_user_id'] === (int)$currentUser['id']` (or match `$purchase['buyer_email']` to the authenticated user's email) before rendering or running enrolment. For guest checkouts, gate the page behind a one-time signed claim token tied to the purchase rather than the raw Stripe session id, and never render `buyer_email` to an unauthenticated viewer.
Filed by the automated tenant-app audit and adversarially evidence-verified. Status: verified. Open — not yet actioned.
Patrick Bass
@mobieus