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

+149
View File
@@ -0,0 +1,149 @@
<?php
namespace App\Controller;
use App\Content\FormRepository;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Attribute\Route;
/**
* Public form submission. There is no session and no CSRF token here on purpose: the route is
* anonymous and changes nothing that belongs to a visitor, so a forged request could at worst
* send a message the spam protection already covers. Starting a session would put a cookie on
* every public page, which the project avoids.
*/
final class FormController extends AbstractController
{
public function __construct(
private FormRepository $forms,
private PageRepository $pages,
private MenuRepository $menus,
private MailerInterface $mailer,
private string $secret,
private string $siteOrigin,
) {
}
#[Route('/formular/{slug}', name: 'form_submit', requirements: ['slug' => '[a-z0-9-]{1,80}'], methods: ['POST'])]
public function submit(Request $request, string $slug): Response
{
$form = $this->forms->findBySlug($slug);
$path = trim($request->request->getString('page'), '/') ?: 'home';
$page = $this->pages->findPublished($path);
if (null === $form || null === $page) {
throw $this->createNotFoundException('Formular oder Seite nicht gefunden.');
}
$senderHash = hash_hmac('sha256', (string) $request->getClientIp(), $this->secret);
// Three quiet checks before the real validation. A caught bot is answered like a success
// so it learns nothing, but nothing is stored and nothing is sent.
$silentlyDropped = '' !== trim($request->request->getString('website'))
|| !$this->timingIsPlausible($request)
|| $this->forms->rateLimited($senderHash);
$checked = $this->forms->check($form, $request->request->all());
if (!$silentlyDropped && [] !== $checked['errors']) {
// Re-render the page the form sits on, with the entered values and the messages.
return $this->renderPage($page, $form, $checked, new Response(status: 422));
}
if (!$silentlyDropped) {
$submission = $this->forms->store($form['id'], $checked['values'], $checked['consent'], $senderHash);
if ($this->deliver($form, $checked['values'])) {
$this->forms->markDelivered($submission);
}
}
// Post/Redirect/Get so a reload does not send the message twice.
return $this->redirect(('home' === $path ? '/' : '/'.$path).'?gesendet='.rawurlencode($slug).'#'.rawurlencode($request->request->getString('block')), 303);
}
/** The signed timestamp makes the age of the form checkable without storing anything. */
private function timingIsPlausible(Request $request): bool
{
$issued = $request->request->getInt('issued');
if (!hash_equals(hash_hmac('sha256', (string) $issued, $this->secret), $request->request->getString('issuedSignature'))) {
return false;
}
$age = time() - $issued;
return $age >= FormRepository::MIN_SECONDS && $age <= FormRepository::MAX_SECONDS;
}
private function deliver(array $form, array $values): bool
{
$lines = [];
foreach ($form['fields'] as $field) {
$lines[] = $field['label'].': '.($values[$field['name']] ?? '');
}
$email = (new Email())
->from($form['settings']['recipient'])
->to($form['settings']['recipient'])
->subject($form['settings']['subject'])
->text(implode("\n", $lines)."\n\n-- \nGesendet über ".rtrim($this->siteOrigin, '/'));
$replyTo = $form['settings']['replyToField'];
if ('' !== $replyTo && '' !== ($values[$replyTo] ?? '')) {
$email->replyTo($values[$replyTo]);
}
try {
$this->mailer->send($email);
return true;
} catch (TransportExceptionInterface) {
// The submission is already stored, so a broken mail setup loses no enquiry.
// It stays marked as undelivered and is visible in the administration.
return false;
}
}
private function renderPage(array $page, array $form, array $checked, Response $response): Response
{
$navigation = [];
foreach ($this->menus->all() as $slot => $menu) {
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']);
}
$issued = time();
$states = [];
$walk = function (array $blocks) use (&$walk, &$states, $page, $form, $checked, $issued): void {
foreach ($blocks as $block) {
if ('form' === ($block['type'] ?? null)) {
$slug = $block['props']['form'] ?? '';
$mine = $slug === $form['slug'];
$states[$block['id']] = [
'form' => $mine ? $form : $this->forms->findBySlug($slug),
'sent' => false,
// Only the form that was submitted shows messages and keeps its input.
'errors' => $mine ? $checked['errors'] : [],
'values' => $mine ? $checked['values'] : [],
'blockId' => $block['id'], 'page' => $page,
'issued' => $issued,
'issuedSignature' => hash_hmac('sha256', (string) $issued, $this->secret),
];
}
$walk($block['children'] ?? []);
}
};
$walk($page['document']['blocks'] ?? []);
$response = $this->render('page.html.twig', [
'page' => $page,
'canonical' => rtrim($this->siteOrigin, '/').('home' === $page['path'] ? '/' : '/'.$page['path']),
'navigation' => $navigation,
'postLists' => [],
'postMeta' => null,
'needsTabs' => false,
'forms' => $states,
], $response);
$response->headers->set('Cache-Control', 'no-store');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Content-Security-Policy', "default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
return $response;
}
}