Nach 1.5.2 meldete der Editor im Browser "The requested module './editor-model.js' does not provide an export named 'dropList'", und der Werkzeugkasten war leer. Ursache war nicht der Code, sondern das Zwischenspeichern. public/.htaccess setzt fuer jede .js-Datei Cache-Control: immutable, max-age=31536000 - ein Jahr. Die Fassungskennung haengt aber nur an den Einstiegsdateien, die die Vorlage schreibt. Die fuenf Module, die der Editor ueber import './...js' laedt, tragen keine: der Browser holte das neue editor.js und behielt das alte editor-model.js. Ein neues canvas-overlay.js traf damit auf ein Modell ohne dropList. Beide gemeldeten Symptome hatten diese eine Wurzel. Seit 1.5.1 baut das Skript den Werkzeugkasten auf, und editor.js importiert canvas-overlay.js in Zeile 5; bricht der Import, laeuft keine Zeile danach und die Elementliste bleibt leer. Das gemeldete fehlende Menue war der Werkzeugkasten, nicht die Verwaltungsnavigation - die wurde geprueft und ist in Chrome 152 in Ordnung. Behebung: eine Importkarte in base.html.twig haengt die Fassung an die aufgeloeste Adresse jedes Moduls, ohne dass eine Importzeile sie kennen muss. Das Zwischenspeichern ueber ein Jahr bleibt - die Leistung war nie das Problem, nur die fehlende Kennung. Eine Importkarte muss eingebettet sein; die Spezifikation kennt keine externe. Unter script-src 'self' wurde sie wortlos verworfen. AdminHeadersSubscriber erzeugt deshalb je Verwaltungsseite einen Nonce, nennt ihn in der Richtlinie und legt ihn in die Anfrage; csp_nonce() gibt ihn der Karte mit. Der Nonce erlaubt genau dieses eine Skript, 'self' gilt unveraendert fuer alles andere. Geprueft: der Browsertest verlangt an jeder angeforderten .js-Adresse eine Fassungskennung (vorher fehlten fuenf, jetzt tragen alle acht), dazu ein Test, der jeden Modulimport gegen die Importkarte haelt - Gegenprobe gemacht. npm test 28/28, phpunit 452/452, npm run test:editor vollstaendig. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
63 lines
2.9 KiB
PHP
63 lines
2.9 KiB
PHP
<?php
|
|
|
|
namespace App\EventSubscriber;
|
|
|
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
|
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
|
use Symfony\Component\HttpKernel\KernelEvents;
|
|
|
|
final class AdminHeadersSubscriber implements EventSubscriberInterface
|
|
{
|
|
public function __construct(private string $adminPath)
|
|
{
|
|
}
|
|
|
|
public static function getSubscribedEvents(): array
|
|
{
|
|
return [KernelEvents::REQUEST => ['onRequest', 300], KernelEvents::RESPONSE => ['onResponse', -300]];
|
|
}
|
|
|
|
private function isAdmin(string $path): bool
|
|
{
|
|
return 1 === preg_match('#^/'.preg_quote($this->adminPath, '#').'(?:/|$)#', $path);
|
|
}
|
|
|
|
/**
|
|
* One value per administration page, for the single inline script there is: the import map.
|
|
*
|
|
* It has to be inline - the specification knows no external import maps - and it has to work
|
|
* under `script-src 'self'`. Without it the map is dropped without a word, the modules load
|
|
* without a version key, and a year-old `editor-model.js` out of the cache meets a new
|
|
* `editor.js`. That is not a theory: it broke the editor after 1.5.0 went out.
|
|
*/
|
|
public function onRequest(RequestEvent $event): void
|
|
{
|
|
$request = $event->getRequest();
|
|
if (!$event->isMainRequest() || !$this->isAdmin($request->getPathInfo())) {
|
|
return;
|
|
}
|
|
$request->attributes->set('csp_nonce', rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '='));
|
|
}
|
|
|
|
public function onResponse(ResponseEvent $event): void
|
|
{
|
|
if (!$this->isAdmin($event->getRequest()->getPathInfo())) {
|
|
return;
|
|
}
|
|
$headers = $event->getResponse()->headers;
|
|
$headers->set('Cache-Control', 'private, no-store');
|
|
$headers->set('X-Robots-Tag', 'noindex, nofollow');
|
|
$headers->set('X-Content-Type-Options', 'nosniff');
|
|
$headers->set('Referrer-Policy', 'same-origin');
|
|
$headers->set('Permissions-Policy', 'accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()');
|
|
// The preview must show the same typeface as the published page, so the font hosts are
|
|
// allowed here as well. Everything else stays as narrow as on the public side.
|
|
// The nonce only ever adds the one script it names; 'self' keeps applying to everything
|
|
// else, so nothing is loosened by it.
|
|
$nonce = $event->getRequest()->attributes->get('csp_nonce');
|
|
$script = is_string($nonce) && '' !== $nonce ? sprintf("'self' 'nonce-%s'", $nonce) : "'self'";
|
|
$headers->set('Content-Security-Policy', "default-src 'self'; script-src {$script}; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
|
}
|
|
}
|