Area: Admin deep-dive (commerce/config) (audit p15b) · Surface: /admin/gate (savePaywallContent) → /gate paywall page (templates/gate/paywall.php) · Dimension: security · Severity: major
A Tenant Admin (role 4) can save arbitrary HTML in the paywall body. The save handler does not purify it, and the public paywall page renders it with strip_tags() which is well known to be insufficient against XSS because it preserves attributes on the allowed tags (onerror/onload/onmouseover handlers and javascript: hrefs). The paywall page is shown to every gated visitor, so this is a privilege-boundary crossing: a role-4 admin executes arbitrary JavaScript in the browser of every visitor including the role-5 Tenant Super Admin, enabling session/cookie theft and CSRF-token exfiltration. OWASP A03:2021 (Injection / Cross-Site Scripting).
Evidence
AdminGateController::savePaywallContent stores the body verbatim with no HTML sanitization: platform/src/Controllers/AdminGateController.php:251 `$body = trim($_POST['paywall_body'] ?? '');` then GateConfig::update(['paywall_body' => $body !== '' ? $body : null]) (line 253-256). The public paywall template then renders it through strip_tags only: platform/templates/gate/paywall.php:39-48 `$allowedTags = '<p><br><strong><em><a><img><ul><ol><li><h2><h3><h4><div><span><hr>'; $sanitizedBody = strip_tags($bodyHtml, $allowedTags); ... <div class="gt-paywall__body"><?= $sanitizedBody ?></div>`. strip_tags keeps the allowed tags but strips NONE of their attributes, so `<img src=x onerror=alert(document.cookie)>`, `<a href="javascript:...">`, or `<div onmouseover=...>` all survive and execute. The in-template comment even acknowledges 'inline styles flow through.'
Suggested fix. Run paywall_body through HTMLPurifier (App\Services\MarkdownService::purifyLongForm()) either on save in savePaywallContent() or at render in paywall.php, instead of strip_tags(). HTMLPurifier strips dangerous attributes and javascript: URIs while keeping the same semantic tag set.
Filed by the automated tenant-app audit and adversarially evidence-verified. Status: verified. Open — not yet actioned.
Patrick Bass
@mobieus