Files
BattleForge/public/index.php
T
Keith Solomon 3185bc3602
CI / php (push) Failing after 1m17s
fix: detect stale CSRF cookie pair and auto-rotate
When a browser session predates a server restart with a fresh
secret.key, the old __csrf HMAC cookie is no longer verifiable
under the new secret. Every action POST returns 403 until the user
manually clears cookies, because the existing-pair check on the
front controller was 'is the cookie present?' rather than 'does
the cookie verify under the current secret?'.

The new check at the top of the front controller computes the
expected HMAC of the raw-token cookie under the current secret and
constant-time-compares it to the __csrf cookie. If they don't match,
the controller re-mints a fresh pair on the response, replacing the
browser's stale cookies with a working set. The next click of 'Play
bundled' then succeeds without user intervention.

Test coverage in tests/Integration/StaleCookieRotationTest.php:
  - testStaleCookiesAreReplacedOnNextRequest
  - testValidCookiesAreNotReIssuedOnNextRequest
  - testMissingTokenCookieIsFilledInOnNextRequest
2026-07-26 16:07:51 -05:00

208 lines
8.8 KiB
PHP

<?php
declare(strict_types=1);
use BattleForge\Http\CsrfToken;
use BattleForge\Http\Handlers\GetAssets;
use BattleForge\Http\Handlers\GetBattlefieldEditor;
use BattleForge\Http\Handlers\GetBundledScenario;
use BattleForge\Http\Handlers\GetBundledScenarios;
use BattleForge\Http\Handlers\GetHomePage;
use BattleForge\Http\Handlers\GetMatchView;
use BattleForge\Http\Handlers\GetTeamEditor;
use BattleForge\Http\Handlers\PostBattlefieldEditor;
use BattleForge\Http\Handlers\PostImageUpload;
use BattleForge\Http\Handlers\PostMatchAbility;
use BattleForge\Http\Handlers\PostMatchAttack;
use BattleForge\Http\Handlers\PostMatchEndTurn;
use BattleForge\Http\Handlers\PostMatchMove;
use BattleForge\Http\Handlers\PostStartMatch;
use BattleForge\Http\Handlers\PostTeamEditor;
use BattleForge\Http\Request;
use BattleForge\Http\Response;
use BattleForge\Http\Router;
require __DIR__ . '/../vendor/autoload.php';
// 1. Read the app secret.
$secret = getenv('BATTLEFORGE_SECRET');
if ($secret === false || $secret === '') {
$secretFile = __DIR__ . '/../var/secret.key';
if (!is_file($secretFile)) {
if (!is_dir(dirname($secretFile))) {
mkdir(dirname($secretFile), 0700, true);
}
file_put_contents($secretFile, random_bytes(32));
chmod($secretFile, 0600);
}
$secret = file_get_contents($secretFile);
if ($secret === false) {
http_response_code(500);
echo "Failed to read app secret.\n";
return;
}
}
// 2. Ensure the __csrf cookie is set. If absent OR stale, issue a fresh
// token. A cookie pair is "stale" when both cookies are present but
// the HMAC (__csrf) does not match the raw token (__csrf_token) under
// the current secret. This can happen when a browser session predates
// a server restart with a fresh secret.key: the browser still has the
// old __csrf cookie, the new server's secret does not match it, and
// every CSRF check fails. By re-minting on first request, we recover
// the session without requiring the user to clear cookies manually.
// The double-submit pattern stores the raw token in a separate
// readable cookie so client code (meta tag, JS) can echo it back as
// the X-CSRF-Token header. The HMAC cookie is the verification
// oracle; the raw-token cookie rides parallel and is readable by
// document.cookie or the equivalent server-side read.
$cookies = $_COOKIE;
$existingCookie = (string) ($cookies['__csrf'] ?? '');
$existingRawToken = (string) ($cookies['__csrf_token'] ?? '');
$pairIsFresh = false;
if ($existingCookie !== '' && $existingRawToken !== '') {
$expectedHmac = hash_hmac('sha256', $existingRawToken, $secret);
$pairIsFresh = hash_equals($expectedHmac, $existingCookie);
}
if ($existingCookie === '' || $existingRawToken === '' || !$pairIsFresh) {
[$token, $cookie] = CsrfToken::issue($secret);
setcookie('__csrf', $cookie, [
'expires' => time() + 86400,
'path' => '/',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax',
]);
// The raw token rides in a readable cookie so server-side templates
// can echo it into the <meta name="csrf-token"> tag without keeping
// the raw token in `index.php` per-request state. HttpOnly off so the
// server can read it via the request cookie jar; security comes from
// the HMAC verification, not from hiding the raw token (which the
// browser would already see in the meta tag anyway).
setcookie('__csrf_token', $token, [
'expires' => time() + 86400,
'path' => '/',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => false,
'samesite' => 'Lax',
]);
$existingCookie = $cookie;
$existingRawToken = $token;
$cookies['__csrf'] = $cookie;
$cookies['__csrf_token'] = $token;
}
// 3. Compute the upload-endpoint user token (derived from the same secret).
$uploadsToken = hash_hmac('sha256', $secret, 'bf-uploads');
// 4. Determine the request method, path, and content type.
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
$queryString = (string) (parse_url($uri, PHP_URL_QUERY) ?? '');
$contentType = isset($_SERVER['CONTENT_TYPE']) ? (string) $_SERVER['CONTENT_TYPE'] : null;
if ($contentType !== null) {
$semicolon = strpos($contentType, ';');
if ($semicolon !== false) {
$contentType = substr($contentType, 0, $semicolon);
}
$contentType = trim($contentType);
}
// 5. Read the raw body for fetch POSTs that submit JSON.
$rawBody = (string) ($_SERVER['__raw_body'] ?? file_get_contents('php://input'));
// 6. Build a Request with the request data, the CSRF secret, and the upload token.
$server = $_SERVER;
if (!isset($server['__csrf_secret'])) {
$server['__csrf_secret'] = $secret;
}
$server['__uploads_token'] = $uploadsToken;
$request = new Request(
get: $_GET,
post: $_POST,
files: $_FILES,
cookies: $cookies,
server: $server,
queryString: $queryString,
method: $method,
path: $path,
contentType: $contentType,
rawBody: $rawBody,
);
// 7. Configure the router with the eight routes.
$uploadsRoot = __DIR__ . '/../var/uploads';
$placeholderDir = __DIR__ . '/assets/placeholders';
$scenariosDir = __DIR__ . '/assets/scenarios';
$homePage = static function (Request $r, array $p) use ($scenariosDir): Response {
return (new GetHomePage($scenariosDir))->handle($r, $p);
};
$getTeam = static fn (Request $r, array $p): Response => (new GetTeamEditor())->handle($r, $p);
$postTeam = static fn (Request $r, array $p): Response => (new PostTeamEditor())->handle($r, $p);
$getBattlefield = static fn (Request $r, array $p): Response => (new GetBattlefieldEditor())->handle($r, $p);
$postBattlefield = static fn (Request $r, array $p): Response => (new PostBattlefieldEditor())->handle($r, $p);
$postStart = static fn (Request $r, array $p): Response => (new PostStartMatch())->handle($r, $p);
$postUpload = static fn (Request $r, array $p): Response => (new PostImageUpload($uploadsRoot))->handle($r, $p);
$getAssets = static function (Request $r, array $p) use ($placeholderDir, $uploadsRoot): Response {
return (new GetAssets($placeholderDir, $uploadsRoot))->handle($r, $p);
};
$getUploadedAsset = static function (Request $r, array $p) use ($placeholderDir, $uploadsRoot): Response {
$handler = new GetAssets($placeholderDir, $uploadsRoot);
return $handler->handle(
$r,
['kind' => 'uploads', 'userToken' => $p['userToken'] ?? '', 'filename' => $p['filename'] ?? ''],
);
};
$getBundledList = static function (Request $r, array $p) use ($scenariosDir): Response {
return (new GetBundledScenarios($scenariosDir))->handle($r, $p);
};
$getBundledOne = static function (Request $r, array $p) use ($scenariosDir): Response {
return (new GetBundledScenario($scenariosDir))->handle($r, $p);
};
$getMatchView = static fn (Request $r, array $p): Response => (new GetMatchView())->handle($r, $p);
$postMatchMove = static function (Request $r, array $p) use ($secret): Response {
return (new PostMatchMove($secret))->handle($r, $p);
};
$postMatchAttack = static function (Request $r, array $p) use ($secret): Response {
return (new PostMatchAttack($secret))->handle($r, $p);
};
$postMatchAbility = static function (Request $r, array $p) use ($secret): Response {
return (new PostMatchAbility($secret))->handle($r, $p);
};
$postMatchEndTurn = static function (Request $r, array $p) use ($secret): Response {
return (new PostMatchEndTurn($secret))->handle($r, $p);
};
$router = new Router();
$router->add('GET', '/', $homePage);
$router->add('GET', '/scenarios/bundled', $getBundledList);
$router->add('GET', '/scenarios/bundled/{id}', $getBundledOne);
$router->add('GET', '/matches/current', $getMatchView);
$router->add('POST', '/matches/current/move', $postMatchMove);
$router->add('POST', '/matches/current/attack', $postMatchAttack);
$router->add('POST', '/matches/current/ability', $postMatchAbility);
$router->add('POST', '/matches/current/end-turn', $postMatchEndTurn);
$router->add('GET', '/scenarios/{id}/edit/team', $getTeam);
$router->add('POST', '/scenarios/{id}/edit/team', $postTeam);
$router->add('GET', '/scenarios/{id}/edit/battlefield', $getBattlefield);
$router->add('POST', '/scenarios/{id}/edit/battlefield', $postBattlefield);
$router->add('POST', '/scenarios/{id}/start', $postStart);
$router->add('POST', '/assets/upload', $postUpload);
$router->add('GET', '/assets/uploads/{userToken}/{filename}', $getUploadedAsset);
$router->add('GET', '/assets/{kind}/{filename}', $getAssets);
// 8. Dispatch and emit.
$response = $router->dispatch($request);
http_response_code($response->status);
foreach ($response->headers as $name => $value) {
header($name . ': ' . $value);
}
echo $response->body;
return $response->status;