Area: Engagement (audit p9) · Surface: GET /games/guess-byte, GET /games/trivia, GET /games/hangman · Dimension: security · Severity: minor
Because credit-spending happens on a plain GET with no anti-CSRF token, an attacker can force a logged-in victim to spend credits by causing their browser to issue the GET (e.g. an `<img src="https://<tenant>/games/trivia">` or a hidden iframe on any page). This is a CSRF-driven resource-drain (OWASP A01). It can only burn the victim's own credits (no privilege gain), so severity is minor, but state mutation on a GET violates safe-method semantics and the project's own 'spend gated by CSRF on POST' discipline used everywhere else.
Evidence
All three GET handlers deduct credits as a side effect of a GET, with no CSRF token (GET routes carry none). e.g. platform/src/Controllers/GameController.php:79-84 (guessTheByte): `if ($cost > 0 && !UserCredit::canAfford(...)) {...} if ($cost > 0) { UserCredit::spend((int) $user['id'], $cost, 'game_play', 'Guess the Byte'); }` — the same spend-on-GET pattern is in trivia() (lines 167-173) and hangman() (lines 261-267). These are registered as GET in routes.php:558-560. A guess.amount of credits is debited the first time a new game session is created on page load.
Suggested fix. Do not spend credits on the GET render. Move the charge + game-session creation to the existing POST handlers (guessTheBytePost/triviaPost/hangmanPost, which already call validateCsrf()), or require an explicit CSRF-protected 'Start game' POST before debiting. The GET should only display the current session state.
Filed by the automated tenant-app audit and adversarially evidence-verified. Status: verified. Open — not yet actioned.
Patrick Bass
@mobieus