
Contao Headless API-Sicherheit & Formularverarbeitung

Vom sicheren Hafen in die Gefahrenzone (Mutierende Requests)
In den bisherigen Teilen unserer Masterclass haben wir uns in einer sehr komfortablen Blase bewegt. Unser Next.js-Frontend hat fleißig JSON-Daten vom Contao-Backend abgefragt (GET-Requests). Wenn du eine reine Lese-API (Read-Only) baust, ist die Contao Headless API-Sicherheit relativ trivial: Ein paar vernünftige CORS-Header, ein solider HTTP-Cache (Varnish oder Nginx) und das System ist praktisch unangreifbar.
Die Spielregeln ändern sich jedoch schlagartig, wenn die Datenflussrichtung umkehrt. Sobald dein Frontend Daten an das Backend sendet (POST, PUT, DELETE) – sei es durch ein Kontaktformular, eine Newsletter-Anmeldung oder einen Checkout-Prozess im E-Commerce –, betrittst du die Gefahrenzone. In der alten, monolithischen Welt hat Contao diese Arbeit im Hintergrund für uns erledigt: Formular-Markup generieren, CSRF-Token (Cross-Site Request Forgery) als verstecktes HTML-Feld injizieren und bei der synchronen POST-Rückgabe validieren.
Im Headless-Szenario existiert dieses Fangnetz nicht mehr. Unser Next.js-Frontend baut das Formular mit React. Es gibt kein serverseitig vorgerendertes HTML mit versteckten Tokens. Senden wir einfach rohe JSON-Daten an einen ungeschützten Contao-Controller, öffnen wir Tür und Tor für Bot-Spam, gefälschte Anfragen und schädliche Payloads. Wir müssen die Sicherheitsarchitektur von Grund auf neu denken.
Praxis & Code: Den API-Endpunkt mit einem API-Key härten
Der erste und wichtigste Schritt der Contao Headless API-Sicherheit ist die Zugangskontrolle. Wenn dein Next.js-Frontend über Server Actions oder API-Routes mit Contao kommuniziert (Server-to-Server), ist die eleganteste und performanteste Lösung ein statischer API-Key (Bearer Token).
Wir bauen unseren Symfony-Controller aus Teil 4 so um, dass er jede Anfrage rigoros ablehnt, die sich nicht mit dem korrekten kryptografischen Schlüssel im HTTP-Header (Authorization: Bearer <token>) ausweist.
Erstelle oder öffne deinen Formular-Controller in Contao:
Datei: workspace/contao-backend/src/Controller/Api/ContactFormController.php
1<?php
2
3declare(strict_types=1);
4
5namespace App\Controller\Api;
6
7use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
8use Symfony\Component\HttpFoundation\JsonResponse;
9use Symfony\Component\HttpFoundation\Request;
10use Symfony\Component\Routing\Annotation\Route;
11
12#[Route('/api/v1/form/contact', name: 'api_form_contact', methods: ['POST'], defaults: ['_scope' => 'frontend'])]
13final class ContactFormController extends AbstractController
14{
15 // Wir holen uns den in der .env.local definierten API-Key über Dependency Injection
16 public function __construct(
17 private readonly string $apiSharedSecret
18 ) {}
19
20 public function __invoke(Request $request): JsonResponse
21 {
22 // 1. Die harte Zugangskontrolle (API-Key Validierung)
23 $authHeader = $request->headers->get('Authorization');
24
25 if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) {
26 return new JsonResponse(['error' => 'Unauthorized. Missing authentication header.'], 401);
27 }
28
29 $token = substr($authHeader, 7); // Das Wort "Bearer " abschneiden
30
31 // Timing-Attacken durch hash_equals verhindern! Niemals einfach == verwenden.
32 if (!hash_equals($this->apiSharedSecret, $token)) {
33 return new JsonResponse(['error' => 'Forbidden. Invalid API key.'], 403);
34 }
35
36 // 2. Payload extrahieren (nur wenn die Zugangskontrolle bestanden wurde)
37 $payload = json_decode($request->getContent(), true);
38
39 if (!$payload) {
40 return new JsonResponse(['error' => 'Bad Request. Invalid JSON payload.'], 400);
41 }
42
43 // ... Hier folgt später die Geschäftslogik (Validierung, Mail-Versand) ...
44
45 return new JsonResponse(['success' => true, 'message' => 'Secure payload received.']);
46 }
47}Um diesen Service ans Laufen zu bekommen, musst du den $apiSharedSecret Parameter in deiner config/services.yaml registrieren, damit Symfony weiß, welchen Wert es in den Konstruktor injizieren soll:
Datei: workspace/contao-backend/config/services.yaml
1services:
2 _defaults:
3 autowire: true
4 autoconfigure: true
5 # Wir binden die Umgebungsvariable API_SHARED_SECRET an den Parameter
6 bind:
7 $apiSharedSecret: '%env(string:API_SHARED_SECRET)%'
8
9 App\:
10 resource: '../src/'
11 exclude:
12 - '../src/DependencyInjection/'
13 - '../src/Entity/'
14 - '../src/Kernel.php'Und schließlich definierst du dein Geheimnis in der .env.local: API_SHARED_SECRET="dein_super_geheimer_64_zeichen_langer_string"
Mit dieser Maßnahme ist dein Endpunkt bereits massiv gehärtet. Skript-Kiddies und einfache Spam-Bots, die blind auf /api/v1/form/contact feuern, prallen sofort an der 401/403 HTTP-Mauer ab, ohne dass Contao überhaupt Rechenzeit für die Verarbeitung des Payloads verschwendet.

Das CSRF-Dilemma und das Backend-For-Frontend (BFF) Pattern
Wenn Frontend-Entwickler das erste Mal eine Headless-Architektur aufbauen, tappen sie fast immer in dieselbe Falle: Sie lassen den Browser des Nutzers (Client) direkt mit der Contao-API (Server) kommunizieren. Sie bauen ein Kontaktformular in React, schreiben ein fetch('https://api.meine-contao-domain.de/form') in den onSubmit-Handler und wundern sich, warum die Sicherheitsarchitektur zusammenbricht.
Wenn der Browser direkt mit dem Backend spricht, greifen sofort die strengen Web-Sicherheitsrichtlinien. Du musst CORS (Cross-Origin Resource Sharing) für POST-Requests öffnen, was ein Sicherheitsrisiko darstellt. Noch gravierender ist das Thema CSRF (Cross-Site Request Forgery). Wenn du Session-Cookies für eingeloggte User nutzt, könnte eine bösartige Drittseite im Hintergrund einen Request an deine offene API abfeuern.
Um das abzuwehren, nutzt Contao nativ einen CsrfTokenManager (Double-Submit Cookie). Wenn du versuchst, dieses Cookie-basierte Token-System in ein entkoppeltes Next.js-Frontend (das auf einer anderen Domain läuft) zu portieren, wirst du an modernen Browser-Restriktionen wie SameSite=Lax oder SameSite=Strict kläglich scheitern. Safari blockiert domainübergreifende Cookies mittlerweile fast vollständig (Intelligent Tracking Prevention).
Die Senior-Entwickler Lösung: Wir ändern die Architektur. Wir lassen den Browser niemals direkt mit Contao sprechen.
Wir nutzen Next.js als Backend-For-Frontend (BFF). Next.js ist nicht nur ein React-Renderer, sondern besitzt einen eigenen Node.js-Server (Route Handlers & Server Actions). Der Datenfluss sieht so aus:
Der Browser sendet die Formulardaten an den Next.js-Server (geschützt durch Next.js interne CSRF-Mechanismen).
Der Next.js-Server (der sicher in einem Rechenzentrum steht) nimmt die Daten an, hängt unseren geheimen
API_SHARED_SECRETaus Abschnitt 1 als Bearer-Token an und leitet die Anfrage serverseitig (Server-to-Server) an Contao weiter.
Praxis & Code: Die Next.js Server Action als sicherer Proxy
Mit den in Next.js 14 etablierten Server Actions ist dieses Pattern unglaublich elegant umzusetzen. Der API-Key verlässt niemals deinen Next.js-Server, er wird dem Browser nicht offengelegt.
Erstelle in deinem Next.js-Projekt eine Datei für die Server Action:
Datei: workspace/nextjs-frontend/app/actions/submitContactForm.ts
1'use server';
2
3// Diese Funktion läuft AUSSCHLIESSLICH auf dem serverseitigen Node.js Backend!
4export async function submitContactForm(prevState: any, formData: FormData) {
5 // 1. Daten aus dem Frontend-Formular extrahieren
6 const email = formData.get('email');
7 const message = formData.get('message');
8
9 // 2. Eigene Validierung auf dem Next.js Server (optional, aber empfohlen)
10 if (!email || !message) {
11 return { error: 'Bitte füllen Sie alle Felder aus.' };
12 }
13
14 try {
15 // 3. Den sicheren Server-to-Server Request an Contao abfeuern
16 // process.env.CONTAO_API_URL und process.env.CONTAO_API_KEY liegen nur auf dem Server!
17 const response = await fetch(`${process.env.CONTAO_API_URL}/api/v1/form/contact`, {
18 method: 'POST',
19 headers: {
20 'Content-Type': 'application/json',
21 // Hier injizieren wir das Token, das wir im Contao Controller validieren
22 'Authorization': `Bearer ${process.env.CONTAO_API_KEY}`,
23 },
24 body: JSON.stringify({ email, message }),
25 // Wir wollen diese POST-Anfrage niemals cachen
26 cache: 'no-store',
27 });
28
29 const data = await response.json();
30
31 if (!response.ok) {
32 return { error: data.error || 'Fehler bei der Übermittlung an die API.' };
33 }
34
35 // 4. Erfolgreiche Antwort an die React-Komponente zurückgeben
36 return { success: true, message: data.message };
37
38 } catch (error) {
39 console.error('BFF Fetch Error:', error);
40 return { error: 'Ein interner Serverfehler ist aufgetreten.' };
41 }
42}Was haben wir damit erreicht? Wir haben das CSRF-Dilemma vollständig eliminiert. Da Contao nur noch Anfragen vom Next.js-Server (und nicht mehr von wildfremden Browsern) erhält, benötigen wir für diesen Endpunkt keine fehleranfälligen CSRF-Tokens oder offene CORS-Regeln mehr. Der statische API-Key (Bearer) ist als Server-to-Server-Authentifizierung absolut ausreichend und hochsicher. Dein Frontend ist pfeilschnell, und dein Contao-Backend bleibt eine unsichtbare, isolierte Festung.

Payload-Sanitization – Vertraue niemals dem Frontend
Ein Grundsatz, den dir jeder erfahrene Backend-Entwickler mitten in der Nacht im Schlaf aufsagen kann, lautet: "Never trust user input." In der modernen Webentwicklung mit Next.js und React tendieren Teams oft dazu, massive Validierungslogik im Frontend aufzubauen (beispielsweise mit Libraries wie Zod oder Yup). Das ist fantastisch für die User Experience (UX), weil der Nutzer sofort ein rotes Feld sieht, wenn er das "@" in der E-Mail vergisst. Aus Sicherheitsperspektive ist Frontend-Validierung jedoch exakt wertlos.
Ein Angreifer nutzt nicht dein schönes React-Formular. Er öffnet sein Terminal, baut sich einen cURL-Request oder nutzt Postman, schickt den Payload an deinen Next.js-Server (oder direkt an Contao, falls unzureichend gesichert) und injiziert JavaScript-Code (<script>...</script>) oder SQL-Befehle direkt in das Feld "Nachricht".
Wenn diese Rohdaten ungefiltert in die Contao-Datenbank geschrieben oder als E-Mail an den Vertrieb gesendet werden, hast du ein massives Cross-Site Scripting (XSS) Problem. Wir müssen die Daten im Contao-Controller also validieren (Ist das Format korrekt?) und sanitisieren (Sind gefährliche Zeichen enthalten?).
Praxis & Code: Symfony Validator und Contao StringUtil
Wir erweitern nun unseren ContactFormController aus Abschnitt 1. Anstatt die Daten mit einfachen if-Statements zu prüfen, nutzen wir die Enterprise-taugliche Validator-Komponente von Symfony. Für die Bereinigung greifen wir auf Contaos native StringUtil-Klasse zurück.
Datei: workspace/contao-backend/src/Controller/Api/ContactFormController.php (Erweiterung)
1<?php
2
3declare(strict_types=1);
4
5namespace App\Controller\Api;
6
7use Contao\StringUtil;
8use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
9use Symfony\Component\HttpFoundation\JsonResponse;
10use Symfony\Component\HttpFoundation\Request;
11use Symfony\Component\Routing\Annotation\Route;
12use Symfony\Component\Validator\Constraints as Assert;
13use Symfony\Component\Validator\Validator\ValidatorInterface;
14
15#[Route('/api/v1/form/contact', name: 'api_form_contact', methods: ['POST'], defaults: ['_scope' => 'frontend'])]
16final class ContactFormController extends AbstractController
17{
18 public function __construct(
19 private readonly string $apiSharedSecret,
20 // Wir injizieren den Symfony Validator
21 private readonly ValidatorInterface $validator
22 ) {}
23
24 public function __invoke(Request $request): JsonResponse
25 {
26 // ... (Zugangskontrolle aus Abschnitt 1 hier beibehalten) ...
27
28 $payload = json_decode($request->getContent(), true);
29
30 // 1. Validierung: Wir definieren strikte Regeln für unseren Payload
31 $constraints = new Assert\Collection([
32 'email' => [
33 new Assert\NotBlank(['message' => 'Die E-Mail-Adresse darf nicht leer sein.']),
34 new Assert\Email(['message' => 'Die angegebene E-Mail-Adresse ist ungültig.']),
35 ],
36 'message' => [
37 new Assert\NotBlank(['message' => 'Die Nachricht darf nicht leer sein.']),
38 new Assert\Length([
39 'min' => 10,
40 'max' => 5000,
41 'minMessage' => 'Die Nachricht muss mindestens 10 Zeichen lang sein.',
42 'maxMessage' => 'Die Nachricht ist zu lang.'
43 ]),
44 ],
45 ]);
46
47 // Symfony prüft den Array-Payload gegen unsere Regeln
48 $violations = $this->validator->validate($payload, $constraints);
49
50 if (count($violations) > 0) {
51 // Fehlermeldungen sauber für das Frontend aggregieren
52 $errors = [];
53 foreach ($violations as $violation) {
54 // Den Feldnamen bereinigen (Symfony gibt z.B. "[email]" zurück)
55 $field = trim($violation->getPropertyPath(), '[]');
56 $errors[$field] = $violation->getMessage();
57 }
58
59 return new JsonResponse(['success' => false, 'errors' => $errors], 422);
60 }
61
62 // 2. Sanitization: Die Daten sind formal gültig, aber noch potenziell gefährlich!
63 // Wir nutzen Contaos StringUtil, um HTML-Tags und Insert-Tags rigoros zu entfernen.
64 $safeEmail = StringUtil::stripInsertTags(strip_tags($payload['email']));
65 $safeMessage = StringUtil::stripInsertTags(strip_tags($payload['message']));
66
67 // Optional: Kodierung für die sichere HTML-Ausgabe (verhindert XSS komplett)
68 $safeMessage = StringUtil::specialchars($safeMessage);
69
70 // 3. Verarbeitung der sauberen Daten (z.B. E-Mail versenden oder in DB speichern)
71 // ... $this->mailer->send(...) mit $safeEmail und $safeMessage
72
73 return new JsonResponse(['success' => true, 'message' => 'Anfrage sicher verarbeitet.']);
74 }
75}Was passiert hier genau? Durch die Assert\Collection weiß unser API-Endpunkt exakt, wie das JSON-Paket aussehen muss. Sendet ein Bot zusätzliche, unvorhergesehene Felder (wie z. B. 'is_admin': true), werden diese vom Validator ignoriert oder werfen einen Fehler, falls wir exakte Übereinstimmung konfigurieren.
Noch wichtiger ist Schritt 2: Selbst wenn das Feld message den Text <script>alert(1)</script> enthält (was eine formell gültige Zeichenkette von über 10 Zeichen ist), löscht strip_tags das HTML, und StringUtil::stripInsertTags verhindert, dass jemand Contao-spezifische Tokens (wie {{file::...}}) einschmuggelt und ausführt.

Der Endgegner – Datei-Uploads headless absichern
Wenn reguläre POST-Requests (wie Text-Eingaben) die Gefahrenzone sind, dann sind Datei-Uploads der absolute Endgegner in der Web-Sicherheit. Ein winziger Fehler bei der Validierung reicht aus, und ein Angreifer lädt eine als .pdf oder .jpg getarnte PHP-Web-Shell auf deinen Server hoch. Sobald diese Datei über den Browser aufgerufen wird, übernimmt der Hacker die vollständige Kontrolle über dein System.
In einer klassischen Contao-Umgebung schützt uns das Formular-Framework: Es gleicht Dateiendungen mit einer Whitelist ab, prüft Mime-Types, bereinigt Dateinamen und synchronisiert den Upload automatisch mit dem Datenbank-gestützten Dateisystem (Dbafs). Wenn wir diese Formulare nun Headless über Next.js bauen, müssen wir diesen extrem komplexen und sicherheitskritischen Flow in unserer API exakt nachbauen.
Die Architektur-Entscheidung: Wir übertragen Dateien von Next.js zu Contao nicht als fehleranfällige Base64-Strings im JSON-Payload (das bläht die Anfrage um 33 % auf und zerstört den RAM deines Servers). Stattdessen nutzt unser BFF (Backend-For-Frontend) aus Abschnitt 2 echten multipart/form-data-Traffic. Contao empfängt die Datei über das native Symfony HttpFoundation-Objekt.
Praxis & Code: Den Datei-Upload sicher in Contao verarbeiten
Wir erweitern unseren ContactFormController um die Fähigkeit, Dateianhänge (z. B. einen Lebenslauf für ein Bewerbungsformular) sicher entgegenzunehmen, zu validieren und in das Contao-Dateisystem zu integrieren.
Datei: workspace/contao-backend/src/Controller/Api/ContactFormController.php (Upload-Erweiterung)
1<?php
2
3declare(strict_types=1);
4
5namespace App\Controller\Api;
6
7use Contao\Dbafs;
8use Contao\File;
9use Contao\Folder;
10use Contao\StringUtil;
11use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
12use Symfony\Component\HttpFoundation\File\UploadedFile;
13use Symfony\Component\HttpFoundation\JsonResponse;
14use Symfony\Component\HttpFoundation\Request;
15use Symfony\Component\Routing\Annotation\Route;
16use Symfony\Component\Validator\Constraints as Assert;
17use Symfony\Component\Validator\Validator\ValidatorInterface;
18
19#[Route('/api/v1/form/upload', name: 'api_form_upload', methods: ['POST'], defaults: ['_scope' => 'frontend'])]
20final class ContactFormController extends AbstractController
21{
22 public function __construct(
23 private readonly string $apiSharedSecret,
24 private readonly ValidatorInterface $validator,
25 private readonly string $projectDir // Der absolute Pfad zur Contao-Installation
26 ) {}
27
28 public function __invoke(Request $request): JsonResponse
29 {
30 // 1. Authentifizierung (wie in Abschnitt 1)
31 if ($request->headers->get('Authorization') !== 'Bearer ' . $this->apiSharedSecret) {
32 return new JsonResponse(['error' => 'Unauthorized'], 401);
33 }
34
35 // 2. Die Datei aus dem multipart/form-data Request greifen
36 /** @var UploadedFile|null $uploadedFile */
37 $uploadedFile = $request->files->get('attachment');
38
39 if (!$uploadedFile) {
40 return new JsonResponse(['error' => 'Keine Datei gefunden.'], 400);
41 }
42
43 // 3. Strikte Datei-Validierung über Symfony
44 $fileConstraints = new Assert\File([
45 'maxSize' => '5M', // Maximal 5 Megabyte
46 'mimeTypes' => [
47 'application/pdf',
48 'image/jpeg',
49 'image/png',
50 ],
51 'mimeTypesMessage' => 'Bitte laden Sie nur PDF- oder JPG/PNG-Bilder hoch.',
52 ]);
53
54 $violations = $this->validator->validate($uploadedFile, $fileConstraints);
55 if (count($violations) > 0) {
56 return new JsonResponse(['error' => $violations[0]->getMessage()], 422);
57 }
58
59 // 4. Dateinamen bereinigen (Sanitization: Verhindert Path Traversal Attacken wie "../../../")
60 $originalFilename = pathinfo($uploadedFile->getClientOriginalName(), PATHINFO_FILENAME);
61 $safeFilename = StringUtil::standardize($originalFilename);
62 $extension = $uploadedFile->guessExtension();
63 $finalFilename = $safeFilename . '_' . uniqid() . '.' . $extension;
64
65 // 5. Den sicheren Zielordner in Contao anlegen (falls er nicht existiert)
66 // WICHTIG: Speichere sensible User-Uploads NIE in /public, sondern in geschützten /files Unterordnern!
67 $uploadDirectory = 'files/secure_uploads/' . date('Y-m');
68 new Folder($uploadDirectory);
69
70 try {
71 // 6. Die Datei physisch in das Contao-Verzeichnis verschieben
72 $movedFile = $uploadedFile->move(
73 $this->projectDir . '/' . $uploadDirectory,
74 $finalFilename
75 );
76
77 // 7. Datenbank-Synchronisation (Dbafs)
78 // Dies ist der wichtigste Contao-spezifische Schritt! Ohne Dbafs "weiß" Contao nicht,
79 // dass diese Datei existiert (z.B. für die Anzeige im Backend-Dateimanager).
80 $contaoFile = new File($uploadDirectory . '/' . $finalFilename);
81 $fileModel = Dbafs::addResource($contaoFile->path);
82
83 return new JsonResponse([
84 'success' => true,
85 'message' => 'Datei erfolgreich hochgeladen und gesichert.',
86 'uuid' => StringUtil::binToUuid($fileModel->uuid) // Optional: UUID für spätere Verknüpfungen zurückgeben
87 ]);
88
89 } catch (\Exception $e) {
90 return new JsonResponse(['error' => 'Dateiverarbeitung fehlgeschlagen.'], 500);
91 }
92 }
93}Die Sicherheits-Meilensteine dieses Codes: Wir verknüpfen hier das Beste aus zwei Welten. Symfony übernimmt die unbestechliche, binäre Validierung des Mime-Types (es reicht nicht, eine Datei einfach in .pdf umzubenennen, Symfony prüft den echten Datei-Header). Wir verhindern Directory Traversal durch StringUtil::standardize() und einen uniqid()-Zusatz, der das Überschreiben bestehender Dateien verhindert.
Zuletzt führen wir den wichtigsten Schritt in der Contao-Architektur aus: Wir rufen Dbafs::addResource() auf. Das ist der Moment, in dem aus einer "dummen" physischen Datei auf der Festplatte ein echtes, in der Datenbank registriertes Contao-Asset wird, das fortan eine UUID besitzt und z. B. als E-Mail-Anhang versendet oder im Backend-Dateimanager angezeigt werden kann.

Die letzte Verteidigungslinie – Rate Limiting & Spam-Schutz
Wir haben unsere Endpunkte mit API-Keys verriegelt, den Payload gnadenlos bereinigt und Datei-Uploads auf Herz und Nieren geprüft. Dennoch bleibt ein Angriffsvektor bestehen: Volumen-basierte Angriffe (Spam & DDoS).
Denk an das Backend-For-Frontend (BFF) Pattern aus Abschnitt 2 zurück. Unser Next.js-Server darf mit Contao kommunizieren, weil er den API-Key kennt. Was passiert aber, wenn ein Bot-Netzwerk das Kontaktformular in deinem Next.js-Frontend 10.000 Mal pro Minute ausfüllt? Next.js nimmt die Daten an, signiert sie pflichtbewusst mit dem Key und leitet den Tsunami an Contao weiter. Contao validiert 10.000 Requests, verschickt 10.000 E-Mails über den SMTP-Server und deine Domain landet postwendend auf einer globalen Spam-Blacklist.
Wir müssen unserem Contao-Controller beibringen, dass selbst authentifizierte Anfragen gedrosselt werden, wenn sie ein gewisses Limit überschreiten. Hierfür nutzen wir die native Symfony RateLimiter Komponente.
Praxis & Code: Den Formular-Controller drosseln
Zunächst konfigurieren wir ein Limit in der Symfony-Konfiguration. Wir erlauben maximal 5 Kontaktanfragen pro IP-Adresse (oder in unserem Fall pro API-Key/System) innerhalb von 15 Minuten.
Erstelle oder öffne die Datei config/packages/rate_limiter.yaml:
Datei: workspace/contao-backend/config/packages/rate_limiter.yaml
1framework:
2 rate_limiter:
3 # Wir definieren einen "contact_form" Limiter
4 contact_form:
5 # Die Sliding-Window-Strategie ist ideal für APIs
6 policy: 'sliding_window'
7 limit: 5
8 interval: '15 minutes'Nun injizieren wir diesen Limiter in unseren ContactFormController und setzen ihn ganz an den Anfang der Verarbeitungskette.
Datei: workspace/contao-backend/src/Controller/Api/ContactFormController.php (Finale Erweiterung)
1<?php
2
3declare(strict_types=1);
4
5namespace App\Controller\Api;
6
7use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
8use Symfony\Component\HttpFoundation\JsonResponse;
9use Symfony\Component\HttpFoundation\Request;
10use Symfony\Component\Routing\Annotation\Route;
11use Symfony\Component\RateLimiter\RateLimiterFactory; // Neu importieren
12
13#[Route('/api/v1/form/contact', name: 'api_form_contact', methods: ['POST'], defaults: ['_scope' => 'frontend'])]
14final class ContactFormController extends AbstractController
15{
16 public function __construct(
17 private readonly string $apiSharedSecret,
18 // Symfony injiziert automatisch die Factory für unseren konfigurierten Limiter
19 private readonly RateLimiterFactory $contactFormLimiter
20 ) {}
21
22 public function __invoke(Request $request): JsonResponse
23 {
24 // 1. Rate Limiting (Noch VOR der Payload-Prüfung!)
25 // Wir nutzen die Client-IP als Identifikator (oder den API-Key, je nach Architektur)
26 $limiter = $this->contactFormLimiter->create($request->getClientIp());
27
28 $limit = $limiter->consume(1);
29
30 if (!$limit->isAccepted()) {
31 return new JsonResponse([
32 'error' => 'Too Many Requests. Bitte warten Sie 15 Minuten, bevor Sie eine neue Anfrage senden.'
33 ], 429, [
34 // Dem Client mitteilen, wann er wieder senden darf
35 'Retry-After' => $limit->getRetryAfter()->getTimestamp()
36 ]);
37 }
38
39 // 2. Zugangskontrolle (API-Key)
40 if ($request->headers->get('Authorization') !== 'Bearer ' . $this->apiSharedSecret) {
41 return new JsonResponse(['error' => 'Unauthorized'], 401);
42 }
43
44 // ... restliche Validierung und Verarbeitung (aus Abschnitt 3 & 4) ...
45
46 return new JsonResponse(['success' => true, 'message' => 'Anfrage sicher verarbeitet.']);
47 }
48}Mit diesen wenigen Zeilen Code (und dank der Power von Symfony unter der Haube von Contao) haben wir einen massiven Riegel vor Spam-Attacken geschoben. Sobald die 6. Anfrage eintrifft, blockiert der Controller sofort mit einem ressourcenschonenden 429 Too Many Requests.
Fazit: API-Sicherheit erfordert Eigenverantwortung
Wenn du dich für eine Headless-Architektur entscheidest, tauschst du vorgefertigte Bequemlichkeit gegen maximale Kontrolle und Performance. In der Welt der mutierenden POST-Requests (Formulare, Uploads, Logins) bedeutet das: Du bist der Türsteher.
Wie wir in diesem Teil 6 gesehen haben, lässt sich diese Verantwortung mit den richtigen Architektur-Patterns souverän meistern:
Das BFF-Pattern (Backend-For-Frontend) mit Next.js Server Actions löst das komplexe CSRF-Dilemma, da der Browser niemals direkt mit der Contao-API spricht.
Ein starker statischer API-Key schützt deine Endpunkte vor unbefugtem Zugriff.
Der Symfony Validator und die Contao StringUtil-Klasse garantieren, dass eingehende Payloads (und vor allem gefährliche Datei-Uploads) bereinigt werden, bevor sie in der Datenbank landen.
Der Rate Limiter bewahrt dein System vor Überlastung.
Mit diesem Setup ist deine Contao API nicht nur rasend schnell, sondern auch eine uneinnehmbare Festung.

Contao Headless Masterclass: High-Performance mit Next.js Pillar
Contao Headless Architektur: Das Konzept im Detail verstehen
Projektstruktur & Entwicklungsumgebung für Contao und Next.js
Contao Headless Basis-Setup: Installation & Bildkonfiguration
Contao Headless Datenstruktur & Redakteurs-Erlebnis
Die API From Scratch entwickeln: Symfony Routing in Contao 5
Contao Headless API-Sicherheit & Formularverarbeitung
Häufig gestellte Fragen (FAQ)
Nein. Wenn du das BFF-Pattern nutzt (Next.js kommuniziert serverseitig mit Contao), findet kein Cross-Origin-Request im Browser statt. Die Requests kommen vom Node.js-Server aus dem Rechenzentrum. Deine API sollte (aus Sicherheitsgründen) keine offenen CORS-Header für POST-Requests an externe Browser-Domains senden.
Unser statischer API-Key (API_SHARED_SECRET) ist perfekt für Server-to-Server-Kommunikation. Wenn du jedoch eine Architektur baust, bei der sich Endkunden einloggen (Mitgliederbereich) und der Browser direkt mit Contao kommunizieren muss, reicht ein statischer Key nicht aus. In diesem Fall implementierst du JWTs (z. B. mit dem LexikJWTAuthenticationBundle), die den individuellen Login-Status des jeweiligen Users kurzzeitig repräsentieren.
Dein nächster Schritt: Phase 3 – Die Next.js Frontend Revolution
Die Architektur steht. Das Contao-Backend ist als pfeilschnelle, typsichere und absolut sichere Headless-API konfiguriert. Wir haben alle Daten, die wir brauchen.
Jetzt ist es an der Zeit, das Backend zu verlassen und die Bühne für das Frontend zu räumen. Im nächsten großen Cluster, Phase 3: Das Next.js App Router Frontend, bauen wir die Website auf.
Du wirst lernen, wie wir die Catch-All-Routen in Next.js konfigurieren, um unsere Contao-Daten abzufragen. Wir implementieren typsichere React-Komponenten, lösen verschachtelte Layouts und nutzen die neuesten Server Components (RSC) für eine SEO-optimierte, lichtschnelle Auslieferung.
Jetzt starten: Teil 7 – Next.js Setup & Dynamisches App-Routing

Dietrich Bojko
Senior Webentwickler
Webinteger arbeitet seit vielen Jahren produktiv mit
Linux-basierten Entwicklungsumgebungen unter Windows.
Der Fokus liegt auf
performanten Setups mit WSL 2, Docker, PHP, Node.js und modernen
Build-Tools in realen Projekten –
nicht auf theoretischen Beispielkonfigurationen.
Die Artikel dieser Serie entstehen direkt aus dem täglichen Einsatz in Kunden- und Eigenprojekten und dokumentieren bewusst auch typische Fehler, Engpässe und bewährte Workarounds.


