From d15dfc6835f95833326468de5bf41e53f36da68d Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Mon, 6 Jul 2026 23:22:42 -0500 Subject: [PATCH] fix: escape scenario id when serialising saved page PostTeamEditor interpolated the scenario id into a ' would break out of the JS-string context and execute in the browser. Use json_encode with JSON_HEX_TAG / HEX_AMP / HEX_APOS / HEX_QUOT so the id is always a safe JS string literal. Adds a PostTeamEditorTest case that submits '' and asserts the literal '' substring is absent from the response. Updates the existing happy-path and FullFlowTest assertions to match the new 'scenario:"demo"' (quoted) form. --- src/Http/Handlers/PostTeamEditor.php | 3 ++- tests/Integration/FullFlowTest.php | 2 +- tests/Integration/PostTeamEditorTest.php | 22 +++++++++++++++++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/Http/Handlers/PostTeamEditor.php b/src/Http/Handlers/PostTeamEditor.php index 6eb286c..3b39eb3 100644 --- a/src/Http/Handlers/PostTeamEditor.php +++ b/src/Http/Handlers/PostTeamEditor.php @@ -34,6 +34,7 @@ final class PostTeamEditor $json = json_encode($this->scenarioToArray($scenario), JSON_THROW_ON_ERROR); $url = (string) ($params['id'] ?? $scenario->id); + $safeUrl = json_encode($url, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); $body = << @@ -41,7 +42,7 @@ final class PostTeamEditor

Scenario saved.

diff --git a/tests/Integration/FullFlowTest.php b/tests/Integration/FullFlowTest.php index f07ffcd..c9b05d4 100644 --- a/tests/Integration/FullFlowTest.php +++ b/tests/Integration/FullFlowTest.php @@ -45,7 +45,7 @@ final class FullFlowTest extends TestCase $teamBody = $this->runFrontController(); self::assertSame(200, $this->lastStatus); self::assertStringContainsString('localStorage.setItem', $teamBody); - self::assertStringContainsString('scenario:demo', $teamBody); + self::assertStringContainsString('scenario:"demo"', $teamBody); // Step 3: POST battlefield editor. $_SERVER['REQUEST_METHOD'] = 'POST'; diff --git a/tests/Integration/PostTeamEditorTest.php b/tests/Integration/PostTeamEditorTest.php index c814f9f..9a6fb0c 100644 --- a/tests/Integration/PostTeamEditorTest.php +++ b/tests/Integration/PostTeamEditorTest.php @@ -46,7 +46,7 @@ final class PostTeamEditorTest extends TestCase self::assertSame(200, $response->status); self::assertStringContainsString('localStorage.setItem', $response->body); - self::assertStringContainsString('scenario:demo', $response->body); + self::assertStringContainsString('scenario:"demo"', $response->body); } public function testItRejectsAnOutOfBoundsStatAndReRendersTheForm(): void @@ -67,6 +67,26 @@ final class PostTeamEditorTest extends TestCase self::assertStringContainsString('outside archetype bounds', $response->body); } + public function testItEscapesAMaliciousIdSoItCannotBreakOutOfTheScriptBlock(): void + { + [$token, $cookie] = CsrfToken::issue(self::SECRET); + $handler = new PostTeamEditor(); + $post = $this->validPost($token); + $post['id'] = ''; + $request = $this->buildRequest( + post: $post, + cookies: ['__csrf' => $cookie], + server: ['__csrf_secret' => self::SECRET], + ); + $response = $handler->handle($request, ['id' => $post['id']]); + + self::assertSame(200, $response->status); + // The literal payload that would execute must NOT appear verbatim in the body. + self::assertStringNotContainsString('', $response->body); + // The JSON-encoded id is what we want to see, with the