Area: Forums (audit p2) · Surface: POST /forums/{slug}/subscribe (ForumController@subscribeForum) · Dimension: Broken Access Control / Business Logic (OWASP A01:2021) · Severity: critical
A logged-in user can obtain permanent, free access to any paid forum by simply POSTing to /forums/{slug}/subscribe and abandoning the Stripe checkout page (or just letting the redirect 302 fire and closing the tab). The 'active' subscription row is created before the user ever pays, and because no Stripe webhook handles the 'forum_subscription' metadata type, that row is never reverted to 'pending'/'past_due'/expired. Forum::hasPaidAccess() — which gates canView()/canPost()/passesAccessRule('paid') for the whole paid-forum feature — returns true for any 'active' row, so the attacker reads and posts in the paid forum indefinitely without payment. This applies to both recurring ('monthly'/'yearly') and one-time ('once') price intervals.
Evidence
platform/src/Controllers/ForumController.php:1113-1118 — immediately after StripeCheckout::createSession() and BEFORE redirecting the user to Stripe, the controller writes an ACTIVE subscription row:
$this->db->execute(
"INSERT INTO forum_subscriptions (forum_id, user_id, stripe_subscription_id, status)
VALUES (:fid, :uid, :sid, 'active')
ON DUPLICATE KEY UPDATE status = 'active', ...",
['fid'=>$forum['id'],'uid'=>$user['id'],'sid'=>$result['session_id'] ?? '']);
The redirect to the Stripe checkout page happens AFTER this insert (line 1120 $this->redirect($result['url'])). Access is then granted on any 'active' row: platform/src/Models/Forum.php:445-449 hasPaidAccess() — "SELECT id FROM forum_subscriptions WHERE forum_id=:fid AND user_id=:uid AND status='active'" → returns true. The paid table defaults status to 'active' (platform/database/migrations/2026-05-26-paid-forums.sql:14 `status enum(...) NOT NULL DEFAULT 'active'`). grep confirms NO webhook ever reconciles forum subscriptions: the only writes to this table's status column are the pre-payment INSERT (ForumController.php:1114) and the cancel UPDATE (ForumController.php:1134); TenantStripeController.php has zero references to forum_subscription/forum_id (grep returned empty).
Suggested fix. Do not write an 'active' subscription on checkout creation. Either (a) insert with a non-granting status (e.g. add a 'pending' enum value and INSERT status='pending'), and flip to 'active' only from a verified Stripe webhook (checkout.session.completed / invoice.paid) keyed on the session/subscription id; or (b) remove the pre-payment INSERT entirely and have the webhook upsert the row on payment success. Add a handler in TenantStripeController for metadata.mobieus_type='forum_subscription' that validates the event signature, then upserts (forum_id,user_id,status='active',current_period_end). Ensure hasPaidAccess() also checks current_period_end for recurring plans.
Filed by the automated tenant-app audit and adversarially evidence-verified. Status: verified. Open — not yet actioned.
Patrick Bass
@mobieus