', $html);
// An older document keeps plain text semantics, so nothing there is ever printed as markup.
$old = DemoContent::starter('Einfach');
$old['schemaVersion'] = 3;
$old['blocks'][1]['children'][1]['props']['text'] = $markup;
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $old, 2), 3);
$plain = $this->request('/')->getContent();
self::assertStringContainsString('<strong>starker</strong>', $plain);
self::assertStringNotContainsString('
starker', $plain);
self::assertStringContainsString('
', $plain);
}
public function testFormattedTextInASharedAreaIsFilteredTheSameWay(): void
{
$header = $this->partials->findByKind('header');
$document = $header['document'];
$document['schemaVersion'] = 12;
$document['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text'] =
'kant cms';
$this->partials->save($header['id'], $document, $header['version']);
$stored = $this->partials->findByKind('header')['document'];
self::assertSame('kant cms', $stored['blocks'][0]['children'][0]['children'][0]['children'][0]['props']['text']);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('kant cms', $html);
self::assertStringNotContainsString('alert(1)', $html);
}
public function testOversizedFormattedTextIsRefused(): void
{
$document = DemoContent::starter('Zu lang');
$document['schemaVersion'] = 12;
$document['blocks'][1]['children'][1]['props']['text'] = str_repeat('a', RichText::MAX_LENGTH + 1);
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
/**
* @return iterable
*/
public static function ownCssCases(): iterable
{
yield 'declarations' => ['color: red; padding: 12px', '#b1{color:red;padding:12px}'];
yield 'nested state' => ['&:hover { color: #000 }', '#b1:hover{color:#000}'];
yield 'descendant keeps its space' => ['& .block-text { font-size: 20px }', '#b1 .block-text{font-size:20px}'];
yield 'child combinator' => ['& > p { margin: 0 }', '#b1 > p{margin:0}'];
yield 'local image' => ['background: url(/bild.png)', '#b1{background:url(/bild.png)}'];
}
#[DataProvider('ownCssCases')]
public function testOwnCssIsScopedToItsElement(string $css, string $expected): void
{
self::assertSame($expected, (new CustomCode())->compileCss('b1', $css));
}
/**
* @return iterable
*/
public static function refusedOwnCss(): iterable
{
yield 'escaping the block' => ['color:red} body{display:none'];
yield 'foreign selector' => ['body { color: red }'];
yield 'at rule' => ['@import url(https://evil.example/x.css)'];
yield 'media query' => ['@media (max-width: 100px) { color: red }'];
yield 'javascript url' => ['background: url(javascript:alert(1))'];
yield 'protocol relative url' => ['background: url(//evil.example/x.png)'];
yield 'backslash escape' => ['content: "\\65"'];
yield 'old expression syntax' => ['color: expression(alert(1))'];
yield 'no value' => ['color'];
yield 'nonsense property' => ['co lor: red'];
}
#[DataProvider('refusedOwnCss')]
public function testRefusedOwnCssNeverReachesADocument(string $css): void
{
$document = DemoContent::starter('Mit CSS');
$document['schemaVersion'] = 13;
$document['blocks'][0]['css'] = $css;
try {
$this->pages->saveDraft('page-1', $document, 0);
self::fail(sprintf('Expected "%s" to be refused.', $css));
} catch (InvalidDocument) {
}
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', ['page-1']));
}
public function testOwnCssReachesThePageStylesheetAndOwnScriptItsOwnFile(): void
{
$document = DemoContent::starter('Mit eigenem Code');
$document['schemaVersion'] = 13;
$document['blocks'][0]['css'] = 'letter-spacing: 2px; &:hover { opacity: 0.8 }';
$document['blocks'][0]['script'] = "element.dataset.ready = 'yes';";
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$response = $this->request('/');
$html = $response->getContent();
self::assertStringContainsString('#start-hero{letter-spacing:2px}', $html);
self::assertStringContainsString('#start-hero:hover{opacity:0.8}', $html);
// The script is a file, never inline, and the policy opens only for this origin.
self::assertStringContainsString('', $html);
self::assertStringNotContainsString('element.dataset.ready', $html);
self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy'));
$script = $this->request('/assets/seite/page-1.js');
self::assertSame(200, $script->getStatusCode());
self::assertStringContainsString('text/javascript', (string) $script->headers->get('Content-Type'));
// Each script runs in its own closure with its element already looked up.
self::assertStringContainsString('document.getElementById("start-hero")', $script->getContent());
self::assertStringContainsString("element.dataset.ready = 'yes';", $script->getContent());
// A page without own script keeps the strict policy and loads no file.
$plain = $this->publishedPage('ohne-code', 'Ohne Code');
self::assertNotNull($this->pages->findForEditor($plain));
$other = $this->request('/ohne-code');
self::assertStringNotContainsString('/assets/seite/', $other->getContent());
self::assertStringContainsString("script-src 'none'", (string) $other->headers->get('Content-Security-Policy'));
self::assertSame(404, $this->request('/assets/seite/'.$plain.'.js')->getStatusCode());
}
public function testADraftScriptIsNotServedToVisitors(): void
{
$document = DemoContent::starter('Nur Entwurf');
$document['schemaVersion'] = 13;
$document['blocks'][0]['script'] = "element.dataset.draft = 'yes';";
$this->pages->saveDraft('page-1', $document, 0);
// Saved but not published, so the script file must not exist yet.
self::assertSame(404, $this->request('/assets/seite/page-1.js')->getStatusCode());
}
public function testOwnCodeNeedsSchemaThirteen(): void
{
$document = DemoContent::starter('Zu alt');
$document['schemaVersion'] = 12;
$document['blocks'][0]['css'] = 'color: red';
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $document, 0);
}
public function testScriptChangesAreComparedAgainstWhatIsStored(): void
{
$code = new CustomCode();
$stored = DemoContent::starter('Mit Skript');
$stored['schemaVersion'] = 13;
$stored['blocks'][0]['script'] = "element.dataset.a = '1';";
// Same scripts: an editor may keep saving the page.
$unchanged = $stored;
$unchanged['title'] = 'Anderer Titel';
$code->assertScriptsUnchanged($unchanged, $stored);
foreach (['changed' => "element.dataset.a = '2';", 'removed' => null] as $case => $script) {
$attempt = $stored;
if (null === $script) {
unset($attempt['blocks'][0]['script']);
} else {
$attempt['blocks'][0]['script'] = $script;
}
try {
$code->assertScriptsUnchanged($attempt, $stored);
self::fail(sprintf('Expected a %s script to be refused.', $case));
} catch (InvalidDocument) {
}
}
// And adding one to a page that had none is refused as well.
$added = DemoContent::starter('Neu');
$added['schemaVersion'] = 13;
$before = $added;
$added['blocks'][0]['script'] = 'alert(1)';
$this->expectException(InvalidDocument::class);
$code->assertScriptsUnchanged($added, $before);
}
private function request(string $url, string $method = 'GET'): Response
{
$previous = [];
foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) {
$previous[$key] = [$_SERVER[$key] ?? null, $_ENV[$key] ?? null];
$_SERVER[$key] = $_ENV[$key] = $value;
}
$kernel = new Kernel('test', false);
try {
return $kernel->handle(Request::create($url, $method));
} finally {
$kernel->shutdown();
foreach ($previous as $key => [$server, $env]) {
if (null === $server) {
unset($_SERVER[$key]);
} else {
$_SERVER[$key] = $server;
}
if (null === $env) {
unset($_ENV[$key]);
} else {
$_ENV[$key] = $env;
}
}
}
}
}