Zum Inhalt springen

PHP-Mailformulare gegen Header-Injection absichern

Wie Header-Injection funktioniert, warum eine einzelne Zeilenumbruchprüfung nicht genügt, und welche Massnahmen Formularspam abfangen, ohne echte Anfragen zu blockieren.

Zuletzt geprüft: Gedacht für: eigene Kontaktformulare

Das Wichtigste

  • Header-Injection entsteht, wenn Nutzereingaben ungeprüft in Mail-Header wandern. Ein Zeilenumbruch im Betreff genügt, um beliebige Empfänger zu ergänzen.
  • Nie mail() mit zusammengebauten Header-Strings. Eine Bibliothek wie PHPMailer oder Symfony Mailer prüft die Header selbst.
  • Der Absender der Mail ist deine Domain. Die Nutzeradresse gehört in Reply-To, sonst scheitert SPF.
  • Ein Honeypot-Feld plus Zeitmessung filtert einfache Bots, ohne Nutzer mit einem Rätsel zu belasten.

Ein Kontaktformular ist schnell geschrieben und schnell missbraucht. Die beiden Probleme sind unterschiedlich: Header-Injection macht den Server zur Spamschleuder, Formularspam füllt nur das Postfach. Beide brauchen andere Gegenmassnahmen.

Header-Injection

Mail-Header werden durch Zeilenumbrüche getrennt. Wer eine Nutzereingabe ungeprüft in einen Header schreibt, erlaubt dem Absender, weitere Header anzuhängen:

Betreff-Feld enthält:
  Anfrage%0ABcc:%20opfer1@example.org,opfer2@example.org

Ergebnis in der Nachricht:

Subject: Anfrage
Bcc: opfer1@example.org,opfer2@example.org

Der Server verschickt die Mail nun zusätzlich an diese Adressen. Das Formular ist damit ein offener Versanddienst – die IP landet auf Blocklisten, und der Betreiber merkt es meist erst, wenn die eigene Post nicht mehr ankommt.

Die Lösung: gar keine Header selbst bauen

<?php
use PHPMailer\PHPMailer\PHPMailer;

$name    = trim($_POST['name']    ?? '');
$email   = trim($_POST['email']   ?? '');
$message = trim($_POST['message'] ?? '');

// Validieren, nicht reparieren
if ($name === '' || $message === '') {
    http_response_code(422);
    exit('Bitte Name und Nachricht ausfüllen.');
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    http_response_code(422);
    exit('Bitte eine gültige Mailadresse angeben.');
}
// Mehrzeilige Eingaben gehören nie in einen Header
if (preg_match('/[\r\n]/', $name . $email)) {
    http_response_code(400);
    exit('Ungültige Eingabe.');
}

$mail = new PHPMailer(true);
$mail->CharSet = 'UTF-8';

// Absender ist IMMER die eigene Domain - sonst scheitern SPF und DMARC
$mail->setFrom('formular@example.com', 'Kontaktformular example.com');
$mail->addAddress('kontakt@example.com');
$mail->addReplyTo($email, $name);

$mail->Subject = 'Kontaktanfrage von der Webseite';
$mail->Body    = "Name: {$name}\nE-Mail: {$email}\n\n{$message}";
$mail->send();

Drei Punkte, die den Unterschied machen:

  1. setFrom mit eigener Domain. Wer die Nutzeradresse als Absender setzt, versendet unter fremder Domain. SPF prüft die IP deines Servers gegen den SPF-Eintrag von gmail.com – das schlägt fehl, und mit p=reject wird die Mail abgewiesen. Die Nutzeradresse gehört in Reply-To.
  2. Validieren statt bereinigen. Eine ungültige Eingabe wird abgelehnt, nicht zurechtgebogen. Wer versucht, gefährliche Zeichen herauszufiltern, übersieht regelmässig eine Kodierung.
  3. Bibliothek statt mail(). PHPMailer und Symfony Mailer bauen die Header selbst und weisen Zeilenumbrüche in Adressfeldern ab.

Spam abfangen, ohne Nutzer zu ärgern

Honeypot. Ein Feld, das per CSS versteckt ist und von Menschen nie ausgefüllt wird:

<div aria-hidden="true" style="position:absolute;left:-9999px">
  <label>Webseite <input type="text" name="website" tabindex="-1" autocomplete="off"></label>
</div>
if (($_POST['website'] ?? '') !== '') {
    http_response_code(200);   // still verwerfen, kein Fehler zurückgeben
    exit;
}

Wichtig ist der Erfolgsstatus: Wer eine Fehlermeldung zurückgibt, verrät dem Bot die Regel. display:none ist die schwächere Variante, weil manche Bots darauf achten – die Positionierung ausserhalb des Sichtbereichs ist unauffälliger. aria-hidden und tabindex="-1" halten das Feld aus Screenreadern und der Tab-Reihenfolge heraus.

Zeitmessung. Ein Zeitstempel im Formular, signiert, damit er nicht manipulierbar ist:

// beim Ausliefern
$ts   = time();
$sig  = hash_hmac('sha256', (string) $ts, $secret);

// beim Absenden
$ts  = (int) ($_POST['ts'] ?? 0);
$ok  = hash_equals(hash_hmac('sha256', (string) $ts, $secret), $_POST['sig'] ?? '');
$age = time() - $ts;
if (!$ok || $age < 3 || $age > 3600) {
    http_response_code(200);
    exit;
}

Unter drei Sekunden hat kein Mensch ein Formular ausgefüllt. Über einer Stunde ist die Seite vermutlich aus einem Cache oder Skript heraus abgeschickt worden.

Rate-Begrenzung. Mehr als drei Absendungen pro IP und Stunde sind bei einem Kontaktformular praktisch immer automatisiert. Ein Zähler in einer Datei oder im Cache reicht dafür aus.

Wenn kein PHP zur Verfügung steht

Bei statisch ausgelieferten Seiten übernimmt ein externer Formulardienst den Versand. Die Absicherung verschiebt sich damit zum Anbieter, aber drei Dinge bleiben deine Aufgabe:

  • Honeypot-Feld im Formular, das der Dienst auswertet.
  • Eine echte Dankeseite als Ziel nach dem Absenden – ohne sie weiss der Nutzer nicht, ob die Anfrage angekommen ist, und im Tracking fehlt das Ereignis.
  • Ein Hinweis in der Datenschutzerklärung, welcher Dienst die Daten verarbeitet und wo.

Häufige Fragen

Was genau ist Header-Injection?

Mail-Header werden durch Zeilenumbrüche getrennt. Schreibt ein Angreifer in ein Formularfeld einen Zeilenumbruch gefolgt von Bcc und einer Adressliste, landet das im Kopf der Nachricht und der Server verschickt die Mail an diese Adressen. So werden Kontaktformulare zu Spamschleudern.

Reicht es, Zeilenumbrüche zu entfernen?

Es ist die Mindestmassnahme und muss alle Varianten abdecken: Wagenrücklauf, Zeilenvorschub, die Kombination beider und die prozentkodierten Formen. Sicherer ist, den Wert gar nicht erst in einen Header zu schreiben, sondern eine Bibliothek zu nutzen, die das selbst prüft.

Warum landen meine Formularmails im Spam?

Meistens, weil die Adresse des Absenders aus dem Formular als From gesetzt wird. Dann versendet dein Server unter fremder Domain, SPF und DMARC schlagen fehl. Setze eine feste Absenderadresse deiner Domain und die Nutzeradresse als Reply-To.

Brauche ich ein CAPTCHA?

Meistens nicht. Honeypot, Zeitmessung und eine Rate-Begrenzung pro IP fangen den Grossteil des automatisierten Spams ab und kosten keinen Nutzer eine Interaktion. Ein CAPTCHA ist die letzte Stufe, nicht die erste.