Redirect the other www spelling to SITE_ORIGIN, hold roles to the role help and fix the audit findings
SITE_ORIGIN alone decides whether the site is www or not: CanonicalHostSubscriber sends the other spelling there with 301, cms:site:origin writes the setting, the system screen shows it. The roles now match the role help (editor: drafts, publisher: live content, admin: design, settings, users, system). The installer no longer reopens on an unreachable database, the session cookie follows ADMIN_PATH, percent-encoded credentials no longer break the container, consent only accepts same-site decisions, JSON-LD is HTML-safe, own CSS stays in its block, plus the editor, updater, download and public-output findings from the full review (ADR 099, ADR 100). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
45040b2ca6
commit
7eb462ac7b
53 files changed
+1023
-122
No files matched your search
@@ -52,7 +52,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
foreach (DemoContent::areas() as $kind => $area) {
|
||||
$partials->create($kind, 'default', $area['name'], $area['document']);
|
||||
}
|
||||
foreach (['admin' => 'ROLE_ADMIN', 'editor' => 'ROLE_EDITOR'] as $username => $role) {
|
||||
foreach (['admin' => 'ROLE_ADMIN', 'publisher' => 'ROLE_PUBLISHER', 'editor' => 'ROLE_EDITOR'] as $username => $role) {
|
||||
$this->db->insert('cms_user', ['id' => $username, 'username' => $username, 'password_hash' => password_hash('Fixture-only-password', PASSWORD_BCRYPT, ['cost' => 4]), 'roles' => json_encode([$role])]);
|
||||
}
|
||||
}
|
||||
@@ -72,9 +72,11 @@ final class EditorSecurityTest extends WebTestCase
|
||||
[$_SERVER, $_ENV] = $this->previous;
|
||||
}
|
||||
|
||||
private function login(string $username = 'admin'): KernelBrowser
|
||||
/** Ein Client je Test (der Kernel startet nur einmal); ein zweiter Benutzer meldet sich im selben an. */
|
||||
private function login(string $username = 'admin', ?KernelBrowser $client = null): KernelBrowser
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client ??= self::createClient();
|
||||
$client->getCookieJar()->clear();
|
||||
$crawler = $client->request('GET', '/admin/login');
|
||||
$client->submit($crawler->selectButton('Anmelden')->form(['_username' => $username, '_password' => 'Fixture-only-password']));
|
||||
self::assertResponseRedirects('/admin');
|
||||
@@ -84,6 +86,25 @@ final class EditorSecurityTest extends WebTestCase
|
||||
return $client;
|
||||
}
|
||||
|
||||
public function testTheSessionCookieFollowsARenamedAdministration(): void
|
||||
{
|
||||
// Heisst die Verwaltung nicht mehr /admin, muss das Sitzungscookie auf ihren Pfad lauten -
|
||||
// sonst schickt der Browser es nie mit, und jede Anmeldung landet wieder vor der Tuer.
|
||||
$_SERVER['ADMIN_PATH'] = $_ENV['ADMIN_PATH'] = 'stiller-eingang';
|
||||
$client = self::createClient();
|
||||
$crawler = $client->request('GET', '/stiller-eingang/login');
|
||||
self::assertResponseIsSuccessful();
|
||||
$client->submit($crawler->selectButton('Anmelden')->form(['_username' => 'admin', '_password' => 'Fixture-only-password']));
|
||||
self::assertResponseRedirects('/stiller-eingang');
|
||||
// Im Test heisst das Sitzungscookie anders als im Betrieb; der Pfad ist, was zaehlt.
|
||||
$paths = array_map(static fn ($cookie): string => $cookie->getPath(), $client->getCookieJar()->all());
|
||||
self::assertContains('/stiller-eingang', $paths, 'Das Sitzungscookie muss fuer den neuen Pfad gelten.');
|
||||
self::assertNotContains('/admin', $paths);
|
||||
$client->followRedirect();
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSelectorExists('a[href="/stiller-eingang/logout"], form[action="/stiller-eingang/logout"]');
|
||||
}
|
||||
|
||||
public function test404SettingsCreateDraftAndProtectAssignmentFromCsrfAndStaleWrites(): void
|
||||
{
|
||||
$client = $this->login();
|
||||
@@ -109,12 +130,70 @@ final class EditorSecurityTest extends WebTestCase
|
||||
|
||||
public function test404AssignmentRequiresPublicationRights(): void
|
||||
{
|
||||
// Ein Redakteur sieht die Einstellungen gar nicht erst: alles dort wirkt sofort.
|
||||
$client = $this->login('editor');
|
||||
$client->request('GET', '/admin/settings');
|
||||
self::assertSelectorNotExists('#not-found-form');
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
$client->request('POST', '/admin/settings/not-found', ['action' => 'create']);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
||||
// Ein Veroeffentlicher darf die 404-Seite zuordnen, aber die Unternehmensdaten nicht aendern.
|
||||
$this->login('publisher', $client);
|
||||
$client->request('GET', '/admin/settings');
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSelectorExists('#not-found-form');
|
||||
self::assertSelectorNotExists('form[action="/admin/settings/company"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wer was aendern darf, steht in der Rollenhilfe - und hier, damit es so bleibt.
|
||||
*
|
||||
* Jede Zeile: eine Route, die Rolle, die zu wenig ist, und die Antwort 403. Ohne Anmeldung
|
||||
* fuehrt jede dieser Adressen zur Anmeldung. Die Rollen, die duerfen, pruefen die uebrigen Tests.
|
||||
*/
|
||||
public function testEveryLiveChangeNeedsTheRoleTheRoleHelpNames(): void
|
||||
{
|
||||
$tooLittle = [
|
||||
// Design, Unternehmensdaten, System, Benutzer, endgueltiges Loeschen: Administrator.
|
||||
['publisher', 'GET', '/admin/theme'], ['publisher', 'POST', '/admin/theme/save'], ['publisher', 'POST', '/admin/theme/fonts'],
|
||||
['publisher', 'POST', '/admin/settings/company'],
|
||||
['publisher', 'GET', '/admin/system'], ['publisher', 'POST', '/admin/system/wartung'], ['publisher', 'POST', '/admin/system/sicherung'],
|
||||
['publisher', 'GET', '/admin/aufgaben'], ['publisher', 'POST', '/admin/aufgaben/ausfuehren'],
|
||||
['publisher', 'GET', '/admin/benutzer'], ['publisher', 'POST', '/admin/benutzer/neu'],
|
||||
['publisher', 'POST', '/admin/pages/p1/delete'],
|
||||
// Alles, was sofort auf der Website steht: Veroeffentlicher.
|
||||
['editor', 'GET', '/admin/settings'], ['editor', 'POST', '/admin/settings/redirects'], ['editor', 'POST', '/admin/settings/rechtliches'], ['editor', 'POST', '/admin/settings/not-found'],
|
||||
['editor', 'GET', '/admin/partials'], ['editor', 'POST', '/admin/partials/new'],
|
||||
['editor', 'GET', '/admin/menus'], ['editor', 'POST', '/admin/menus/header'],
|
||||
['editor', 'GET', '/admin/forms'], ['editor', 'POST', '/admin/forms/neu'],
|
||||
['editor', 'POST', '/admin/pages/p1/schedule'], ['editor', 'POST', '/admin/api/pages/p1/publish'],
|
||||
];
|
||||
$client = null;
|
||||
foreach (['publisher', 'editor'] as $role) {
|
||||
$client = $this->login($role, $client);
|
||||
foreach (array_filter($tooLittle, static fn (array $row): bool => $row[0] === $role) as [, $method, $path]) {
|
||||
$client->request($method, $path);
|
||||
self::assertSame(403, $client->getResponse()->getStatusCode(), sprintf('%s %s %s', $role, $method, $path));
|
||||
}
|
||||
}
|
||||
// Ein Redakteur sieht in der Navigation nur, was er oeffnen darf.
|
||||
$crawler = $client->request('GET', '/admin');
|
||||
self::assertResponseIsSuccessful();
|
||||
$links = $crawler->filter('.nav-item')->each(static fn ($node): string => (string) $node->attr('href'));
|
||||
foreach (['/admin/partials', '/admin/menus', '/admin/forms', '/admin/theme', '/admin/settings', '/admin/system', '/admin/benutzer'] as $hidden) {
|
||||
self::assertNotContains($hidden, $links, $hidden.' gehoert nicht in die Navigation eines Redakteurs.');
|
||||
}
|
||||
self::assertContains('/admin/media', $links);
|
||||
// Ohne Anmeldung: keine 403-Auskunft, sondern die Anmeldung.
|
||||
$anonymous = $client;
|
||||
$anonymous->getCookieJar()->clear();
|
||||
foreach ([['GET', '/admin'], ['POST', '/admin/api/pages/p1/draft'], ['POST', '/admin/theme/save'], ['POST', '/admin/menus/header']] as [$method, $path]) {
|
||||
$anonymous->request($method, $path);
|
||||
self::assertResponseRedirects('http://localhost/admin/login', null, $method.' '.$path);
|
||||
}
|
||||
// Und ohne Anmeldung ist nichts geschrieben worden.
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
||||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM menu'));
|
||||
}
|
||||
|
||||
public function testEverySharedAreaOpensPreviewsAndSavesWithConflictProtection(): void
|
||||
@@ -263,7 +342,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
|
||||
public function testMenuFormBuildsATreeFromFlatRowsAndReplacesTheFixedNavigation(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
$client = $this->login('publisher');
|
||||
$crawler = $client->request('GET', '/admin/menus');
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
@@ -294,12 +373,12 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertSame(['/', '/#kontakt', 'https://hausklar.app'], $public->extract(['href']));
|
||||
}
|
||||
|
||||
public function testMenuWritesNeedAnEditorRoleACsrfTokenAndTheCurrentVersion(): void
|
||||
public function testMenuWritesNeedAPublisherRoleACsrfTokenAndTheCurrentVersion(): void
|
||||
{
|
||||
$menus = new MenuRepository($this->db, new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||||
$menus->save('header', 'Hauptnavigation', [['label' => 'Start', 'type' => 'page', 'pageId' => 'p1', 'anchor' => '', 'children' => []]], 0);
|
||||
|
||||
$client = $this->login('editor');
|
||||
$client = $this->login('publisher');
|
||||
$crawler = $client->request('GET', '/admin/menus');
|
||||
$token = $crawler->filter('.menu-form input[name="_csrf_token"]')->first()->attr('value');
|
||||
$entry = ['position' => 10, 'level' => 1, 'label' => 'Geaendert', 'type' => 'page', 'pageId' => 'p1', 'anchor' => ''];
|
||||
@@ -519,7 +598,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
$forms->save('kontakt', 'Kontakt', $form['fields'], [...$form['settings'], 'buttonLabel' => 'Anfrage senden', 'fieldBorderColor' => '@ink', 'fieldBackgroundColor' => '#fffdf7', 'buttonColor' => '#124b36'], $form['version']);
|
||||
$this->pageWithForm();
|
||||
|
||||
$client = $this->login('editor');
|
||||
$client = $this->login('publisher');
|
||||
$crawler = $client->request('GET', '/');
|
||||
self::assertResponseIsSuccessful();
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
@@ -550,9 +629,9 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertSame('#124b36', $forms->findBySlug('kontakt')['settings']['buttonColor']);
|
||||
}
|
||||
|
||||
public function testFormAdministrationNeedsAnEditorRoleAndACsrfToken(): void
|
||||
public function testFormAdministrationNeedsAPublisherRoleAndACsrfToken(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
$client = $this->login('publisher');
|
||||
$crawler = $client->request('GET', '/admin/forms');
|
||||
self::assertResponseIsSuccessful();
|
||||
$token = $crawler->filter('.form-def input[name="_csrf_token"]')->first()->attr('value');
|
||||
@@ -797,7 +876,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
|
||||
public function testLegalPagesCanBeCreatedWithoutInjectingFooterLinks(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
$client = $this->login('publisher');
|
||||
$crawler = $client->request('GET', '/admin/settings');
|
||||
// As long as they are missing, the settings screen says so instead of hiding it.
|
||||
self::assertCount(1, $crawler->filter('form[action="/admin/settings/rechtliches"]'));
|
||||
|
||||
Reference in new issue
Block a user