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:
@@ -34,6 +34,7 @@ final class PostTeamEditor
|
|||||||
|
|
||||||
$json = json_encode($this->scenarioToArray($scenario), JSON_THROW_ON_ERROR);
|
$json = json_encode($this->scenarioToArray($scenario), JSON_THROW_ON_ERROR);
|
||||||
$url = (string) ($params['id'] ?? $scenario->id);
|
$url = (string) ($params['id'] ?? $scenario->id);
|
||||||
|
$safeUrl = json_encode($url, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
|
||||||
$body = <<<HTML
|
$body = <<<HTML
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -41,7 +42,7 @@ final class PostTeamEditor
|
|||||||
<body>
|
<body>
|
||||||
<p>Scenario saved.</p>
|
<p>Scenario saved.</p>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
localStorage.setItem('scenario:{$url}', $json);
|
localStorage.setItem('scenario:{$safeUrl}', $json);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ final class FullFlowTest extends TestCase
|
|||||||
$teamBody = $this->runFrontController();
|
$teamBody = $this->runFrontController();
|
||||||
self::assertSame(200, $this->lastStatus);
|
self::assertSame(200, $this->lastStatus);
|
||||||
self::assertStringContainsString('localStorage.setItem', $teamBody);
|
self::assertStringContainsString('localStorage.setItem', $teamBody);
|
||||||
self::assertStringContainsString('scenario:demo', $teamBody);
|
self::assertStringContainsString('scenario:"demo"', $teamBody);
|
||||||
|
|
||||||
// Step 3: POST battlefield editor.
|
// Step 3: POST battlefield editor.
|
||||||
$_SERVER['REQUEST_METHOD'] = 'POST';
|
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ final class PostTeamEditorTest extends TestCase
|
|||||||
|
|
||||||
self::assertSame(200, $response->status);
|
self::assertSame(200, $response->status);
|
||||||
self::assertStringContainsString('localStorage.setItem', $response->body);
|
self::assertStringContainsString('localStorage.setItem', $response->body);
|
||||||
self::assertStringContainsString('scenario:demo', $response->body);
|
self::assertStringContainsString('scenario:"demo"', $response->body);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testItRejectsAnOutOfBoundsStatAndReRendersTheForm(): void
|
public function testItRejectsAnOutOfBoundsStatAndReRendersTheForm(): void
|
||||||
@@ -67,6 +67,26 @@ final class PostTeamEditorTest extends TestCase
|
|||||||
self::assertStringContainsString('outside archetype bounds', $response->body);
|
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, mixed> $post
|
||||||
* @param array<string, string> $cookies
|
* @param array<string, string> $cookies
|
||||||
|
|||||||
Reference in New Issue
Block a user