diff --git a/public/index.php b/public/index.php index d95dea4..7167f4a 100644 --- a/public/index.php +++ b/public/index.php @@ -44,9 +44,15 @@ if ($secret === false || $secret === '') { } // 2. Ensure the __csrf cookie is set. If absent, issue a fresh token. +// 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'] ?? ''); -if ($existingCookie === '') { +$existingRawToken = (string) ($cookies['__csrf_token'] ?? ''); +if ($existingCookie === '' || $existingRawToken === '') { [$token, $cookie] = CsrfToken::issue($secret); setcookie('__csrf', $cookie, [ 'expires' => time() + 86400, @@ -55,8 +61,23 @@ if ($existingCookie === '') { 'httponly' => true, 'samesite' => 'Lax', ]); + // The raw token rides in a readable cookie so server-side templates + // can echo it into the 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). diff --git a/src/Http/Handlers/GetBattlefieldEditor.php b/src/Http/Handlers/GetBattlefieldEditor.php index 1a79d03..20263c0 100644 --- a/src/Http/Handlers/GetBattlefieldEditor.php +++ b/src/Http/Handlers/GetBattlefieldEditor.php @@ -12,7 +12,7 @@ final class GetBattlefieldEditor /** @param array $params */ public function handle(Request $request, array $params): Response { - $csrf = $request->cookies['__csrf'] ?? ''; + $csrf = $request->cookies['__csrf_token'] ?? ''; $scenarioId = $params['id'] ?? 'new'; $width = 8; $height = 8; diff --git a/src/Http/Handlers/GetHomePage.php b/src/Http/Handlers/GetHomePage.php index 62f708a..a81d987 100644 --- a/src/Http/Handlers/GetHomePage.php +++ b/src/Http/Handlers/GetHomePage.php @@ -16,7 +16,7 @@ final class GetHomePage /** @param array $params */ public function handle(Request $request, array $params): Response { - $csrf = $request->cookies['__csrf'] ?? ''; + $csrf = $request->cookies['__csrf_token'] ?? ''; $bundled = $this->loadBundledScenarios(); ob_start(); diff --git a/src/Http/Handlers/GetMatchView.php b/src/Http/Handlers/GetMatchView.php index 31f8521..6558fd8 100644 --- a/src/Http/Handlers/GetMatchView.php +++ b/src/Http/Handlers/GetMatchView.php @@ -12,7 +12,7 @@ final class GetMatchView /** @param array $params */ public function handle(Request $request, array $params): Response { - $csrf = $request->cookies['__csrf'] ?? ''; + $csrf = $request->cookies['__csrf_token'] ?? ''; ob_start(); require_once __DIR__ . '/../../Views/layout.php'; diff --git a/src/Http/Handlers/GetTeamEditor.php b/src/Http/Handlers/GetTeamEditor.php index 0e73d23..0a4a0f5 100644 --- a/src/Http/Handlers/GetTeamEditor.php +++ b/src/Http/Handlers/GetTeamEditor.php @@ -12,7 +12,7 @@ final class GetTeamEditor /** @param array $params */ public function handle(Request $request, array $params): Response { - $csrf = $request->cookies['__csrf'] ?? ''; + $csrf = $request->cookies['__csrf_token'] ?? ''; $scenarioId = $params['id'] ?? 'new'; $error = null; diff --git a/src/Http/Handlers/PostStartMatch.php b/src/Http/Handlers/PostStartMatch.php index d65dd6d..60467b4 100644 --- a/src/Http/Handlers/PostStartMatch.php +++ b/src/Http/Handlers/PostStartMatch.php @@ -27,8 +27,17 @@ final class PostStartMatch try { $payload = json_decode($request->rawBody, true, flags: JSON_THROW_ON_ERROR); - $draft = ScenarioDraft::fromPost($payload); - $scenario = $draft->toScenario(); + // Accept either the canonical `Scenario` shape (used by the bundled + // scenario endpoint at /scenarios/bundled/{id}, where the JS POSTs + // the JSON it fetched verbatim) or the editor's `ScenarioDraft` + // shape (the form's denormalized fields). The presence of a + // nested `battlefield` object distinguishes the two. + if (is_array($payload) && is_array($payload['battlefield'] ?? null)) { + $scenario = ScenarioSerializer::scenarioFromArray($payload); + } else { + $draft = ScenarioDraft::fromPost($payload ?? []); + $scenario = $draft->toScenario(); + } ScenarioValidator::validate($scenario); $match = $scenario->startMatch('alpha'); } catch (\JsonException $exception) { diff --git a/tests/Integration/FullFlowTest.php b/tests/Integration/FullFlowTest.php index c9b05d4..aef72e7 100644 --- a/tests/Integration/FullFlowTest.php +++ b/tests/Integration/FullFlowTest.php @@ -37,6 +37,7 @@ final class FullFlowTest extends TestCase // Step 2: POST team editor. $_COOKIE['__csrf'] = $cookie; + $_COOKIE['__csrf_token'] = $token; $_SERVER['REQUEST_METHOD'] = 'POST'; $_SERVER['REQUEST_URI'] = '/scenarios/demo/edit/team'; $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; @@ -104,6 +105,7 @@ final class FullFlowTest extends TestCase try { [$token, $cookie] = CsrfToken::issue(self::SECRET); $_COOKIE['__csrf'] = $cookie; + $_COOKIE['__csrf_token'] = $token; $_SERVER['REQUEST_METHOD'] = 'POST'; $_SERVER['REQUEST_URI'] = '/assets/upload'; $_SERVER['CONTENT_TYPE'] = 'multipart/form-data; boundary=----test'; diff --git a/tests/Integration/PostBattlefieldEditorTest.php b/tests/Integration/PostBattlefieldEditorTest.php index 6228031..9899ba1 100644 --- a/tests/Integration/PostBattlefieldEditorTest.php +++ b/tests/Integration/PostBattlefieldEditorTest.php @@ -30,7 +30,7 @@ final class PostBattlefieldEditorTest extends TestCase $request = $this->buildRequest( rawBody: json_encode($this->validBattlefield(), JSON_THROW_ON_ERROR), csrfHeader: $token, - cookies: ['__csrf' => $cookie], + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], server: ['__csrf_secret' => self::SECRET], ); $response = $handler->handle($request, ['id' => 'demo']); @@ -48,7 +48,7 @@ final class PostBattlefieldEditorTest extends TestCase $request = $this->buildRequest( rawBody: json_encode(['this' => 'is not a valid scenario'], JSON_THROW_ON_ERROR), csrfHeader: $token, - cookies: ['__csrf' => $cookie], + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], server: ['__csrf_secret' => self::SECRET], ); $response = $handler->handle($request, ['id' => 'demo']); diff --git a/tests/Integration/PostImageUploadTest.php b/tests/Integration/PostImageUploadTest.php index ac44f7e..af1eec5 100644 --- a/tests/Integration/PostImageUploadTest.php +++ b/tests/Integration/PostImageUploadTest.php @@ -52,7 +52,7 @@ final class PostImageUploadTest extends TestCase get: [], post: ['_csrf' => $token], files: ['image' => ['name' => 'a.png', 'tmp_name' => $tmp, 'error' => 0, 'size' => 70, 'type' => 'image/png']], - cookies: ['__csrf' => $cookie], + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], server: ['__csrf_secret' => self::SECRET], queryString: '', method: 'POST', @@ -78,7 +78,7 @@ final class PostImageUploadTest extends TestCase get: [], post: ['_csrf' => $token], files: ['image' => ['name' => 'a.png', 'tmp_name' => $tmp, 'error' => 0, 'size' => 12, 'type' => 'image/png']], - cookies: ['__csrf' => $cookie], + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], server: ['__csrf_secret' => self::SECRET], queryString: '', method: 'POST', diff --git a/tests/Integration/PostMatchActionTest.php b/tests/Integration/PostMatchActionTest.php index 94b91c1..31d8631 100644 --- a/tests/Integration/PostMatchActionTest.php +++ b/tests/Integration/PostMatchActionTest.php @@ -36,7 +36,7 @@ final class PostMatchActionTest extends TestCase match: $match, csrf: $csrf, turnToken: $token, - cookies: ['__csrf' => $cookie], + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], body: [ 'matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), @@ -67,7 +67,7 @@ final class PostMatchActionTest extends TestCase match: $match, csrf: $csrf, turnToken: $token, - cookies: ['__csrf' => $cookie], + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], body: [ 'matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), @@ -100,7 +100,7 @@ final class PostMatchActionTest extends TestCase match: $match, csrf: $csrf, turnToken: $token, - cookies: ['__csrf' => $cookie], + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], body: [ 'matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), @@ -135,7 +135,7 @@ final class PostMatchActionTest extends TestCase match: $match, csrf: $csrf, turnToken: $token, - cookies: ['__csrf' => $cookie], + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], body: [ 'matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), @@ -175,7 +175,7 @@ final class PostMatchActionTest extends TestCase match: $match, csrf: $csrf, turnToken: $token, - cookies: ['__csrf' => $cookie], + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], body: [ 'matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), @@ -220,7 +220,7 @@ final class PostMatchActionTest extends TestCase match: $match, csrf: $csrf, turnToken: $token, - cookies: ['__csrf' => $cookie], + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], body: [ 'matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), @@ -247,7 +247,7 @@ final class PostMatchActionTest extends TestCase match: $match, csrf: $csrf, turnToken: $token, - cookies: ['__csrf' => $cookie], + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], body: [ 'matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), @@ -275,7 +275,7 @@ final class PostMatchActionTest extends TestCase match: $match, csrf: $csrf, turnToken: $stale, - cookies: ['__csrf' => $cookie], + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], body: [ 'matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), @@ -292,7 +292,7 @@ final class PostMatchActionTest extends TestCase match: $match, csrf: $csrf, turnToken: $wrongRound, - cookies: ['__csrf' => $cookie], + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], body: [ 'matchId' => self::MATCH_ID, 'match' => ScenarioSerializer::matchToArray($match), diff --git a/tests/Integration/PostStartMatchTest.php b/tests/Integration/PostStartMatchTest.php index 3280014..58042c3 100644 --- a/tests/Integration/PostStartMatchTest.php +++ b/tests/Integration/PostStartMatchTest.php @@ -29,7 +29,7 @@ final class PostStartMatchTest extends TestCase $request = $this->buildRequest( rawBody: json_encode($this->validScenario(), JSON_THROW_ON_ERROR), csrfHeader: $token, - cookies: ['__csrf' => $cookie], + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], ); $response = $handler->handle($request, ['id' => 'demo']); @@ -51,7 +51,7 @@ final class PostStartMatchTest extends TestCase $request = $this->buildRequest( rawBody: json_encode(['this' => 'is not a valid scenario'], JSON_THROW_ON_ERROR), csrfHeader: $token, - cookies: ['__csrf' => $cookie], + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], ); $response = $handler->handle($request, ['id' => 'demo']); @@ -65,7 +65,7 @@ final class PostStartMatchTest extends TestCase $request = $this->buildRequest( rawBody: json_encode($this->validScenario(), JSON_THROW_ON_ERROR), csrfHeader: $token, - cookies: ['__csrf' => $cookie], + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], ); $response = $handler->handle($request, ['id' => 'demo']); diff --git a/tests/Integration/PostTeamEditorTest.php b/tests/Integration/PostTeamEditorTest.php index 9a6fb0c..828c142 100644 --- a/tests/Integration/PostTeamEditorTest.php +++ b/tests/Integration/PostTeamEditorTest.php @@ -39,7 +39,7 @@ final class PostTeamEditorTest extends TestCase $handler = new PostTeamEditor(); $request = $this->buildRequest( post: $this->validPost($token), - cookies: ['__csrf' => $cookie], + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], server: ['__csrf_secret' => self::SECRET], ); $response = $handler->handle($request, ['id' => 'demo']); @@ -57,7 +57,7 @@ final class PostTeamEditorTest extends TestCase $post['teamA']['units'][0]['maxHealth'] = '9999'; $request = $this->buildRequest( post: $post, - cookies: ['__csrf' => $cookie], + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], server: ['__csrf_secret' => self::SECRET], ); $response = $handler->handle($request, ['id' => 'demo']); @@ -75,7 +75,7 @@ final class PostTeamEditorTest extends TestCase $post['id'] = ''; $request = $this->buildRequest( post: $post, - cookies: ['__csrf' => $cookie], + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], server: ['__csrf_secret' => self::SECRET], ); $response = $handler->handle($request, ['id' => $post['id']]); diff --git a/tests/Unit/Http/MatchActionSupportTest.php b/tests/Unit/Http/MatchActionSupportTest.php index d0d9cbc..1c49a97 100644 --- a/tests/Unit/Http/MatchActionSupportTest.php +++ b/tests/Unit/Http/MatchActionSupportTest.php @@ -47,7 +47,7 @@ final class MatchActionSupportTest extends TestCase match: $this->validMatchArray(), csrf: $csrf, turnToken: $badToken, - cookies: ['__csrf' => $cookie], + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], ); $result = $support->verify($request); @@ -66,7 +66,7 @@ final class MatchActionSupportTest extends TestCase match: ['this' => 'is not a match'], csrf: $csrf, turnToken: 'whatever', - cookies: ['__csrf' => $cookie], + cookies: ['__csrf' => $cookie, '__csrf_token' => $token], ); $result = $support->verify($request);