Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren

Dokumentschema v13. Der neue Reiter "Erweitert" im Editor nimmt zu jedem
Element eigenes CSS und eigenes Skript auf.

Eigenes CSS parst `CustomCode` und schreibt es selbst wieder aus, mit einem
Selektor, den die Klasse setzt und der nie aus der Eingabe stammt. Erlaubt
sind Eigenschaften für das Element und verschachtelte Regeln mit `&`
(`&:hover`, `& > p`, `& .block-text`). Weil neu serialisiert wird, kann eine
überzählige `}` den Block nicht verlassen. Abgewiesen: @-Regeln,
Backslash-Escapes, `expression()`, fremde Selektoren und jede Adresse außer
einem einzelnen `/` oder `https://`. Das darf jeder mit Editorrolle.

Eigenes Skript ist etwas anderes: beliebiges JavaScript auf der
veröffentlichten Website. Deshalb nur für Administratoren, durchgesetzt beim
Schreiben über einen Vergleich mit dem gespeicherten Stand - ein Editor kann
eine Seite mit Skript weiter bearbeiten, das Skript aber nicht anfassen, auch
nicht über eine selbst gebaute Anfrage. Ausgeliefert wird es als
`/assets/seite/{id}.js` und nur für veröffentlichte Seiten, jedes Skript in
einer eigenen Closure mit gesuchtem `element` und `try`/`catch`. Nur eine
Seite mit eigenem Skript sendet `script-src 'self'`, jede andere behält
`script-src 'none'`; inline ist nirgends erlaubt. In der Editorvorschau wird
nichts ausgeführt.

Zwei echte Löcher kamen beim Testen heraus und sind behoben: `& .block-text`
wurde zu `#id.block-text`, weil das führende Leerzeichen wegtrimmt wurde, und
`url(//fremd.example/x.png)` kam durch, weil jede Adresse mit `/` erlaubt war.

201 PHP-Tests / 716 Assertions unter PHP 8.2.12 und 8.4.22, Playwright-
Editortest und öffentliche Browserprüfung grün. Nicht geprüft: der Reiter
"Erweitert" ist nicht von Hand bedient und nicht mit einem Screenreader
getestet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 17:17:16 +02:00
1 parent 790f42213c
commit a22a7f7fd7
15 files changed
+544 -14

No files matched your search

+5 -1
View File
@@ -2,6 +2,7 @@
namespace App\Controller;
use App\Content\CustomCode;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\MenuRepository;
@@ -25,7 +26,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
#[Route('/admin')]
final class EditorController extends AbstractController
{
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CsrfTokenManagerInterface $csrf)
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private CsrfTokenManagerInterface $csrf)
{
}
@@ -362,6 +363,9 @@ final class EditorController extends AbstractController
if (!is_array($body['document'] ?? null)) {
throw new \InvalidArgumentException();
}
if (!$this->isGranted('ROLE_ADMIN')) {
$this->code->assertScriptsUnchanged($body['document'], $page['document']);
}
$page['draft_revision_id'] = $this->pages->saveDraft($id, $body['document'], $body['expectedVersion']);
$page['document'] = $body['document'];
}
+32 -4
View File
@@ -2,6 +2,7 @@
namespace App\Controller;
use App\Content\CustomCode;
use App\Content\FormRepository;
use App\Content\MenuRepository;
use App\Content\PageRepository;
@@ -18,7 +19,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class PageController extends AbstractController
{
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private Theme $theme, private StyleCompiler $styles, private string $secret, private string $siteOrigin)
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Theme $theme, private StyleCompiler $styles, private string $secret, private string $siteOrigin)
{
}
@@ -44,9 +45,9 @@ final class PageController extends AbstractController
$theme = $this->theme->load();
// Theme first, block styles after, so a block always wins over the site defaults.
$sections = $this->sections($page['document'], $areas);
$styles = $this->theme->css($theme).$this->styles->compileDocument($page['document']);
$styles = $this->theme->css($theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
$styles .= $this->styles->compileDocument($extra);
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
}
$googleFonts = $this->theme->googleFontsUrl($theme);
}
@@ -65,6 +66,9 @@ final class PageController extends AbstractController
'postLists' => $this->postLists($page['document']),
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
'needsTabs' => $this->hasType($page['document'], 'tabs'),
// Own scripts arrive as one file per page, never inline, so the policy can stay
// hash based and no page without a script loses its strict setting.
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
'forms' => $this->formStates($page, $request->query->getString('gesendet')),
'breadcrumbs' => $this->breadcrumbs($page),
'structuredData' => $this->structuredData($page),
@@ -75,7 +79,9 @@ final class PageController extends AbstractController
]);
}
return $this->shell($response, null !== $page && $this->hasType($page['document'], 'tabs'), $styles, null !== $googleFonts);
$wantsScript = null !== $page && ($this->hasType($page['document'], 'tabs') || [] !== $this->code->scriptsOf($page['document']));
return $this->shell($response, $wantsScript, $styles, null !== $googleFonts);
}
/**
@@ -301,6 +307,28 @@ final class PageController extends AbstractController
return $lists;
}
/**
* The own scripts of one published page, as a single file.
*
* A file instead of inline markup, so the policy stays hash based and every page without a
* script keeps `script-src 'none'`. Only published content is served: a draft script must not
* run for a visitor.
*/
#[Route('/assets/seite/{id}.js', name: 'page_script', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['GET'])]
public function pageScript(string $id): Response
{
$row = $this->pages->findPublishedById($id);
$script = null === $row ? '' : $this->code->compileScript($row['document']);
if ('' === $script) {
throw $this->createNotFoundException('Diese Seite hat kein eigenes Skript.');
}
$response = new Response($script, 200, ['Content-Type' => 'text/javascript; charset=UTF-8']);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Cache-Control', 'no-store');
return $response;
}
/**
* The header and footer documents. Absent areas simply render nothing, so a site without a
* header is a valid site rather than a broken one.
+5
View File
@@ -2,6 +2,7 @@
namespace App\Controller;
use App\Content\CustomCode;
use App\Content\DocumentValidator;
use App\Content\MenuRepository;
use App\Content\PartialRepository;
@@ -33,6 +34,7 @@ final class PartialController extends AbstractController
private MenuRepository $menus,
private Theme $theme,
private StyleCompiler $styles,
private CustomCode $code,
private CsrfTokenManagerInterface $csrf,
) {
}
@@ -128,6 +130,9 @@ final class PartialController extends AbstractController
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion'])) {
throw new \InvalidArgumentException();
}
if (!$this->isGranted('ROLE_ADMIN')) {
$this->code->assertScriptsUnchanged($document, $partial['document']);
}
$this->partials->save($id, $document, $body['expectedVersion']);
$partial['document'] = $document;
$partial['version'] = $body['expectedVersion'] + 1;