Formulare mit Validierung, Einwilligung und Spamschutz (Arbeitspaket D)

Migration 20260912000700 legt form und form_submission an. Der
Formularblock im Seiteninhalt enthält nur die Adresse: Felder,
Empfänger und Einwilligungstext liegen in der Datenbank, damit ein
gespeichertes Dokument nicht ändern kann, wonach gefragt wird oder
wohin eine Anfrage geht.

Serverseitige Validierung mit einer Meldung je Feld über
aria-describedby und aria-invalid; eine Ablehnung rendert die Seite
mit HTTP 422 und allen Eingaben neu, eine gültige Absendung wird mit
HTTP 303 zurückgeleitet. Alles ohne JavaScript.

Spamschutz ohne Dienst Dritter: Honeypot, signierter Zeitstempel
gegen zu schnelle und zu alte Absendungen, fünf Absendungen je
Absender und Stunde. Ein erkannter Bot bekommt dieselbe Antwort wie
ein Erfolg. Die Absenderadresse wird nie gespeichert, nur ein Hash.

Gespeichert wird vor dem Versand, damit ein kaputter Mailversand
keine Anfrage verliert. Kein CSRF-Token auf dem öffentlichen Weg,
weil das eine Sitzung und damit ein Cookie auf jeder öffentlichen
Seite bedeuten würde; Begründung in ADR 017. Dateiuploads fehlen
bewusst.

Ein echter Fehler von den Tests gefunden: das Stundenfenster des
Limits entstand aus lokaler Zeit, die Zeitstempel aber aus UTC - auf
einer Uhr vor UTC hätte das Limit nie gegriffen.

Geprüft: 117 PHP-Tests / 450 Assertions unter PHP 8.2.12 und 8.4.22,
14 JavaScript-Modelltests, Lint/Format/Container, composer audit.
Der E-Mail-Versand wurde nie wirklich ausgeführt; keine Browser- oder
Lighthouse-Läufe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 11:51:07 +02:00
1 parent c6ec00a906
commit d43bd91069
31 files changed
+1445 -26

No files matched your search

+174 -1
View File
@@ -4,6 +4,7 @@ namespace App\Tests;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\FormRepository;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Infrastructure\ConnectionFactory;
@@ -17,6 +18,8 @@ use DoctrineMigrations\Version20260912000300;
use DoctrineMigrations\Version20260912000400;
use DoctrineMigrations\Version20260912000500;
use DoctrineMigrations\Version20260912000600;
use DoctrineMigrations\Version20260912000700;
use PHPUnit\Framework\Attributes\DataProvider;
use Psr\Log\NullLogger;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
@@ -49,6 +52,7 @@ final class EditorSecurityTest extends WebTestCase
(new Version20260912000400($this->db, new NullLogger()))->up($schema);
(new Version20260912000500($this->db, new NullLogger()))->up($schema);
(new Version20260912000600($this->db, new NullLogger()))->up($schema);
(new Version20260912000700($this->db, new NullLogger()))->up($schema);
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
$this->db->executeStatement($sql);
}
@@ -107,7 +111,7 @@ final class EditorSecurityTest extends WebTestCase
public function testAnonymousCannotReadDraftOrWrite(): void
{
$client = self::createClient();
foreach (['/admin', '/admin/pages/p1', '/admin/api/pages/p1', '/admin/media', '/admin/api/media', '/admin/menus'] as $url) {
foreach (['/admin', '/admin/pages/p1', '/admin/api/pages/p1', '/admin/media', '/admin/api/media', '/admin/menus', '/admin/forms'] as $url) {
$client->request('GET', $url);
self::assertResponseRedirects('http://localhost/admin/login');
self::assertStringNotContainsString('schemaVersion', $client->getResponse()->getContent());
@@ -228,6 +232,175 @@ final class EditorSecurityTest extends WebTestCase
self::assertSame('Neu', $this->db->fetchOne('SELECT name FROM menu WHERE slot = ?', ['header']));
}
private function contactForm(): FormRepository
{
$forms = new FormRepository($this->db);
$forms->save('kontakt', 'Kontakt', [
['name' => 'name', 'type' => 'text', 'label' => 'Name', 'required' => true],
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true],
['name' => 'thema', 'type' => 'select', 'label' => 'Thema', 'required' => true, 'options' => ['Beratung', 'Support']],
['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Nachricht', 'required' => false],
['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich willige ein.'],
], ['recipient' => 'info@kant-it.de', 'subject' => 'Anfrage', 'replyToField' => 'email', 'success' => 'Danke.'], 0);
return $forms;
}
/** Publishes a page carrying the contact form block and returns the page path. */
private function pageWithForm(): string
{
$document = DemoContent::starter('Kontakt');
$document['schemaVersion'] = 8;
$document['blocks'][] = ['id' => 'formblock', 'type' => 'form', 'props' => ['form' => 'kontakt']];
$pages = new PageRepository($this->db, new DocumentValidator());
$revision = $pages->saveDraft('p1', $document, (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['p1']));
$pages->publish('p1', $revision, (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['p1']));
return 'home';
}
/** @return array<string, string> a body that would pass every check */
private function validBody(string $issued): array
{
return [
'page' => 'home', 'block' => 'formblock', 'website' => '',
'issued' => $issued, 'issuedSignature' => hash_hmac('sha256', $issued, 'isolated-test-secret'),
'name' => 'Umut', 'email' => 'umut@example.com', 'thema' => 'Beratung',
'nachricht' => 'Bitte um Rückruf.', 'datenschutz' => 'ja',
];
}
public function testFormRendersWithoutScriptAndStoresAValidSubmissionWithConsent(): void
{
$this->contactForm();
$this->pageWithForm();
$client = self::createClient();
$html = $client->request('GET', '/')->html();
self::assertStringContainsString('action="/formular/kontakt"', $html);
self::assertStringContainsString('Ich willige ein.', $html);
// The honeypot exists but is kept out of the tab order and away from assistive technology.
self::assertStringContainsString('name="website"', $html);
self::assertStringContainsString('tabindex="-1"', $html);
self::assertStringNotContainsString('<script', $client->getResponse()->getContent());
$issued = (string) (time() - 10);
$client->request('POST', '/formular/kontakt', $this->validBody($issued));
// Post/Redirect/Get, so reloading the result page sends nothing twice.
self::assertResponseStatusCodeSame(303);
self::assertStringContainsString('?gesendet=kontakt#formblock', (string) $client->getResponse()->headers->get('Location'));
$row = $this->db->fetchAssociative('SELECT * FROM form_submission');
self::assertNotFalse($row);
$data = json_decode($row['data'], true);
self::assertSame('Umut', $data['name']);
self::assertSame('ja', $data['datenschutz']);
// The moment of consent is stored, the sender address never is.
self::assertNotNull($row['consent_at']);
self::assertSame(64, strlen($row['sender_hash']));
self::assertStringNotContainsString('127.0.0.1', json_encode($row));
// The success message appears on the page the form sits on.
$client->request('GET', '/?gesendet=kontakt');
self::assertStringContainsString('Danke.', $client->getResponse()->getContent());
}
public function testInvalidSubmissionKeepsEveryEntryAndNamesTheOffendingFields(): void
{
$this->contactForm();
$this->pageWithForm();
$client = self::createClient();
$issued = (string) (time() - 10);
$body = [...$this->validBody($issued), 'email' => 'keine-adresse', 'thema' => 'Erfunden', 'name' => '', 'datenschutz' => ''];
$crawler = $client->request('POST', '/formular/kontakt', $body);
self::assertResponseStatusCodeSame(422);
// Nothing is stored and nothing is sent while anything is wrong.
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
$html = $client->getResponse()->getContent();
self::assertStringContainsString('Bitte gib eine gültige E-Mail-Adresse an.', $html);
self::assertStringContainsString('Bitte wähle eine der angebotenen Optionen.', $html);
self::assertStringContainsString('Dieses Feld wird benötigt.', $html);
self::assertStringContainsString('Ohne diese Einwilligung', $html);
// Each message belongs to its field and is announced as such.
self::assertSame('formblock-email-error', $crawler->filter('#formblock-email')->attr('aria-describedby'));
self::assertSame('true', $crawler->filter('#formblock-email')->attr('aria-invalid'));
self::assertCount(1, $crawler->filter('[role=alert]'));
// The entries survive the rejection.
self::assertSame('keine-adresse', $crawler->filter('#formblock-email')->attr('value'));
self::assertStringContainsString('Bitte um Rückruf.', $crawler->filter('#formblock-nachricht')->text());
}
/**
* @return iterable<string, array{callable(string): array}>
*/
public static function droppedSubmissions(): iterable
{
$sign = static fn (string $issued): string => hash_hmac('sha256', $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']];
yield 'timestamp far in the past' => [static function (array $body) use ($sign): array {
$old = (string) (time() - 200000);
return [...$body, 'issued' => $old, 'issuedSignature' => $sign($old)];
}];
}
#[DataProvider('droppedSubmissions')]
public function testSpamProtectionDropsSubmissionsQuietlyWithoutStoringThem(callable $tamper): void
{
$this->contactForm();
$this->pageWithForm();
$client = self::createClient();
$client->request('POST', '/formular/kontakt', $tamper($this->validBody((string) (time() - 10))));
// Answered like a success so an automated sender learns nothing from the response.
self::assertResponseStatusCodeSame(303);
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
}
public function testRateLimitStopsTheSixthSubmissionWithinAnHour(): void
{
$forms = $this->contactForm();
$this->pageWithForm();
$client = self::createClient();
for ($i = 0; $i < FormRepository::RATE_LIMIT; ++$i) {
$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'));
$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')));
}
public function testFormAdministrationNeedsAnEditorRoleAndACsrfToken(): void
{
$client = $this->login('editor');
$crawler = $client->request('GET', '/admin/forms');
self::assertResponseIsSuccessful();
$token = $crawler->filter('.menu-form input[name="_csrf_token"]')->first()->attr('value');
$field = ['fields' => [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => '1']]];
$client->request('POST', '/admin/forms/neu', [...$field, '_csrf_token' => 'wrong', 'slug' => 'kontakt', 'name' => 'Kontakt', 'recipient' => 'info@kant-it.de', 'expectedVersion' => 0]);
self::assertResponseRedirects('/admin/forms');
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form'));
// A recipient that is not an address is refused with a message rather than stored.
$client->request('POST', '/admin/forms/neu', [...$field, '_csrf_token' => $token, 'slug' => 'kontakt', 'name' => 'Kontakt', 'recipient' => 'kein-empfaenger', 'expectedVersion' => 0]);
$client->followRedirect();
self::assertSelectorExists('.notice.error');
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form'));
$client->request('POST', '/admin/forms/neu', [...$field, '_csrf_token' => $token, 'slug' => 'kontakt', 'name' => 'Kontakt', 'recipient' => 'info@kant-it.de', 'expectedVersion' => 0]);
self::assertSame('Kontakt', $this->db->fetchOne('SELECT name FROM form'));
}
public function testMediaUploadReencodingCsrfMetadataConflictAndArchive(): void
{
$client = $this->login('editor');