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
+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