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);