Harden KantSites after the security review
- The installer only opens with a per-installation key that install.php creates and shows; the key is deleted with the first account. - Lock files keep .env, the database, backups and logs out of reach when a domain points at the project folder instead of public/. - Linked sections render by their own schema version, so an old section cannot carry unfiltered HTML into a newer page. - Editors no longer move, trash or change areas and post data of published pages; a scheduled publication goes live with the revision that was scheduled (migration 20261012000100). - Form stamps are bound to their form, IPv6 senders count per /64, and a flood per form or overall stops storing with a visible message. - The consent redirect rejects control characters, update targets must look like 1.2.3, and own CSS cannot reach outside its block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
14e7a3b310
commit
ad7c333215
43 files changed
+810
-53
No files matched your search
@@ -356,6 +356,54 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
||||
}
|
||||
|
||||
public function testEditorsChangeLivePagesOnlyThroughAPublisher(): void
|
||||
{
|
||||
// Eine veröffentlichte Unterseite und ein reiner Entwurf (ADR 116).
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||||
$live = $pages->createPage('leistungen', null, 'page', DemoContent::starter('Leistungen'));
|
||||
$seite = $pages->findForEditor($live);
|
||||
$pages->publish($live, $seite['draft_revision_id'], $seite['version']);
|
||||
$entwurf = $pages->createPage('entwurf', null, 'page', DemoContent::starter('Entwurf'));
|
||||
$version = static fn (string $id): int => (int) $pages->findForEditor($id)['version'];
|
||||
|
||||
$client = $this->login('editor');
|
||||
$token = $client->request('GET', '/admin')->filter('input[name="_csrf_token"]')->last()->attr('value');
|
||||
foreach (['move' => ['slug' => 'umbenannt'], 'trash' => [], 'bereiche' => ['headerArea' => '', 'footerArea' => 'none'], 'meta' => ['robots' => 'noindex']] as $aktion => $felder) {
|
||||
$client->request('POST', '/admin/pages/'.$live.'/'.$aktion, ['_csrf_token' => $token, 'expectedVersion' => $version($live), ...$felder]);
|
||||
self::assertResponseRedirects();
|
||||
}
|
||||
$nachher = $this->db->fetchAssociative('SELECT path, deleted_at, footer_area, robots FROM page WHERE id = ?', [$live]);
|
||||
self::assertSame('leistungen', $nachher['path'], 'Ein Redakteur verschiebt keine veröffentlichte Seite.');
|
||||
self::assertNull($nachher['deleted_at'], 'Ein Redakteur legt keine veröffentlichte Seite in den Papierkorb.');
|
||||
self::assertNull($nachher['footer_area']);
|
||||
self::assertNotSame('noindex', $nachher['robots']);
|
||||
|
||||
// Einen Entwurf, der nie online war, bearbeitet er weiter frei.
|
||||
$client->request('POST', '/admin/pages/'.$entwurf.'/move', ['_csrf_token' => $token, 'expectedVersion' => $version($entwurf), 'slug' => 'neuer-name']);
|
||||
self::assertSame('neuer-name', $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$entwurf]));
|
||||
|
||||
// Wer veröffentlichen darf, darf auch das.
|
||||
$client = $this->login('publisher', $client);
|
||||
$token = $client->request('GET', '/admin')->filter('input[name="_csrf_token"]')->last()->attr('value');
|
||||
$client->request('POST', '/admin/pages/'.$live.'/move', ['_csrf_token' => $token, 'expectedVersion' => $version($live), 'slug' => 'umbenannt']);
|
||||
self::assertSame('umbenannt', $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$live]));
|
||||
}
|
||||
|
||||
public function testAScheduledPublicationPublishesTheRevisionThatWasScheduled(): void
|
||||
{
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||||
$id = $pages->createPage('termin', null, 'page', DemoContent::starter('Geprüfter Stand'));
|
||||
$geprueft = $pages->findForEditor($id)['draft_revision_id'];
|
||||
$pages->schedule($id, '2026-01-01 08:00', (int) $pages->findForEditor($id)['version']);
|
||||
// Nach dem Planen speichert jemand einen anderen Entwurf.
|
||||
$pages->saveDraft($id, DemoContent::starter('Später gespeichert'), (int) $pages->findForEditor($id)['version']);
|
||||
self::assertNotSame($geprueft, $pages->findForEditor($id)['draft_revision_id']);
|
||||
|
||||
self::assertSame(['termin'], $pages->publishDue());
|
||||
self::assertSame($geprueft, $this->db->fetchOne('SELECT published_revision_id FROM page WHERE id = ?', [$id]), 'Online geht der geplante Stand, nicht der spätere Entwurf.');
|
||||
self::assertNull($this->db->fetchOne('SELECT publish_revision_id FROM page WHERE id = ?', [$id]));
|
||||
}
|
||||
|
||||
public function testAdministratorsDeleteAPageWithAllRevisionsOnlyWithACsrfToken(): void
|
||||
{
|
||||
$client = $this->login('admin');
|
||||
@@ -499,7 +547,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
{
|
||||
return [
|
||||
'page' => 'home', 'block' => 'formblock', 'website' => '',
|
||||
'issued' => $issued, 'issuedSignature' => hash_hmac('sha256', $issued, 'isolated-test-secret'),
|
||||
'issued' => $issued, 'issuedSignature' => FormRepository::stamp('kontakt', (int) $issued, 'isolated-test-secret'),
|
||||
'name' => 'Umut', 'email' => 'umut@example.com', 'thema' => 'Beratung',
|
||||
'nachricht' => 'Bitte um Rückruf.', 'datenschutz' => 'ja',
|
||||
];
|
||||
@@ -611,7 +659,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
*/
|
||||
public static function droppedSubmissions(): iterable
|
||||
{
|
||||
$sign = static fn (string $issued): string => hash_hmac('sha256', $issued, 'isolated-test-secret');
|
||||
$sign = static fn (string $issued): string => FormRepository::stamp('kontakt', (int) $issued, 'isolated-test-secret');
|
||||
yield 'honeypot filled' => [static fn (array $body): array => [...$body, 'website' => 'https://spam.example']];
|
||||
yield 'submitted too fast' => [static fn (array $body): array => [...$body, 'issued' => (string) time(), 'issuedSignature' => $sign((string) time())]];
|
||||
yield 'timestamp forged' => [static fn (array $body): array => [...$body, 'issuedSignature' => 'nicht-signiert']];
|
||||
@@ -650,7 +698,47 @@ final class EditorSecurityTest extends WebTestCase
|
||||
$client->request('POST', '/formular/kontakt', $this->validBody((string) (time() - 10)));
|
||||
self::assertResponseStatusCodeSame(303);
|
||||
self::assertSame(FormRepository::RATE_LIMIT, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
|
||||
self::assertTrue($forms->rateLimited(hash_hmac('sha256', '127.0.0.1', 'isolated-test-secret')));
|
||||
self::assertTrue($forms->rateLimited(FormRepository::senderKey('127.0.0.1', 'isolated-test-secret')));
|
||||
}
|
||||
|
||||
public function testSendersOfOneIpv6NetworkCountTogether(): void
|
||||
{
|
||||
// Ein Anschluss hat ein ganzes /64-Netz; jede Adresse daraus ist derselbe Absender (ADR 116).
|
||||
$a = FormRepository::senderKey('2001:db8:1:2:aaaa::1', 's');
|
||||
self::assertSame($a, FormRepository::senderKey('2001:db8:1:2:ffff:1:2:3', 's'));
|
||||
self::assertNotSame($a, FormRepository::senderKey('2001:db8:1:3::1', 's'));
|
||||
self::assertNotSame(FormRepository::senderKey('192.0.2.1', 's'), FormRepository::senderKey('192.0.2.2', 's'));
|
||||
}
|
||||
|
||||
public function testAStampFromOneFormDoesNotOpenAnother(): void
|
||||
{
|
||||
// Das Siegel gilt für ein Formular; eines von einer anderen Seite lässt sich nicht
|
||||
// wiederverwenden (ADR 116). Der Versand wird still verworfen wie bei jedem Bot.
|
||||
$this->contactForm();
|
||||
$this->pageWithForm();
|
||||
$client = self::createClient();
|
||||
$issued = (string) (time() - 10);
|
||||
$client->request('POST', '/formular/kontakt', [...$this->validBody($issued), 'issuedSignature' => FormRepository::stamp('anderes', (int) $issued, 'isolated-test-secret')]);
|
||||
self::assertResponseStatusCodeSame(303);
|
||||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
|
||||
}
|
||||
|
||||
public function testAFloodStopsStoringAndTellsTheVisitor(): void
|
||||
{
|
||||
$forms = $this->contactForm();
|
||||
$this->pageWithForm();
|
||||
$form = $forms->findBySlug('kontakt');
|
||||
// Viele verschiedene Absender in der letzten Stunde: die Grenze je Formular greift.
|
||||
for ($i = 0; $i < FormRepository::FORM_RATE_LIMIT; ++$i) {
|
||||
$forms->store($form['id'], ['name' => 'x'], true, 'absender-'.$i);
|
||||
}
|
||||
self::assertTrue($forms->flooded($form['id']));
|
||||
$client = self::createClient();
|
||||
$client->request('POST', '/formular/kontakt', $this->validBody((string) (time() - 10)));
|
||||
// Sichtbar statt still: ein echter Besucher erfährt, dass er anrufen sollte.
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertStringContainsString('Gerade gehen sehr viele Anfragen ein', (string) $client->getResponse()->getContent());
|
||||
self::assertSame(FormRepository::FORM_RATE_LIMIT, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
|
||||
}
|
||||
|
||||
public function testAFormCarriesItsOwnColoursOntoThePageAndTheAdministrationRefusesBadOnes(): void
|
||||
|
||||
Reference in new issue
Block a user