fix: escape scenario id when serialising saved page

PostTeamEditor interpolated the scenario id into a <script> block as
'localStorage.setItem("scenario:<id>", ...)', so an id containing
'</script><script>...</script>' 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 '</script><script>alert(1)</script>'
and asserts the literal '<script>alert(1)</script>' substring is absent
from the response. Updates the existing happy-path and FullFlowTest
assertions to match the new 'scenario:"demo"' (quoted) form.
This commit is contained in:
Keith Solomon
2026-07-06 23:22:42 -05:00
parent 61abb93e59
commit d15dfc6835
3 changed files with 24 additions and 3 deletions
+2 -1
View File
@@ -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 = <<<HTML
<!DOCTYPE html>
<html lang="en">
@@ -41,7 +42,7 @@ final class PostTeamEditor
<body>
<p>Scenario saved.</p>
<script type="module">
localStorage.setItem('scenario:{$url}', $json);
localStorage.setItem('scenario:{$safeUrl}', $json);
</script>
</body>
</html>
+1 -1
View File
@@ -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';
+21 -1
View File
@@ -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'] = '</script><script>alert(1)</script>';
$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('<script>alert(1)</script>', $response->body);
// The JSON-encoded id is what we want to see, with the </ tag escaped to <\/.
self::assertStringContainsString('<\\/script><script>alert(1)<\\/script>', $response->body);
}
/**
* @param array<string, mixed> $post
* @param array<string, string> $cookies