Area: Cross-cutting infra (audit p14) · Surface: GateMiddleware::renderPaywall -> /gate/checkout · Dimension: security · Severity: major
The inline 402 paywall rendered by GateMiddleware (shown when a user hits a read_gated surface) reads the CSRF token from the wrong session key, so it always renders an empty hidden `_csrf_token`. When the user clicks Subscribe, GateController::checkout() calls validateCsrf(), which hash_equals the real (non-empty) token against the empty submitted value and 403s. The subscribe/checkout button on the inline paywall is therefore broken for every user — a direct revenue/availability defect on the monetization surface. (The standalone GET /gate/paywall page works because GateController::paywall() uses $this->render(), which injects the correct csrfToken; only the middleware-injected paywall is broken.)
Evidence
platform/src/Middleware/GateMiddleware.php:89 — `$csrfToken = $_SESSION['_csrf_token'] ?? '';`. The canonical session key is `csrf_token` (no leading underscore): BaseController.php:425-428 generates `$_SESSION['csrf_token']`, and validation reads `$_SESSION['csrf_token']` (CsrfValidationMiddleware.php:41, BaseController.php:491-493). `grep -rn "_SESSION\['_csrf_token'\]" src/ templates/` returns this single line. The paywall template embeds it: templates/gate/paywall.php:82 `<input type="hidden" name="_csrf_token" value="<?= $e($csrfToken) ?>">`, and the target handler enforces CSRF: GateController.php:30-31 `requireAuth(); validateCsrf();`.
Suggested fix. In GateMiddleware::renderPaywall(), read the correct key and generate one if absent, mirroring BaseController::csrfToken(): `if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } $csrfToken = $_SESSION['csrf_token'];`.
Filed by the automated tenant-app audit and adversarially evidence-verified. Status: verified. Open — not yet actioned.
Patrick Bass
@mobieus