Webentwicklung

Post/Redirect/Get in PHP - Sicher aktualisieren, ohne POST für idempotent zu halten

Post/Redirect/Get in PHP - Sicher aktualisieren, ohne POST für idempotent zu halten

Ein Formular speichert einen neuen Datensatz, der Browser zeigt eine Erfolgsseite an, und dann aktualisiert jemand die Seite. Sollte diese Aktualisierung das Ergebnis erneut anzeigen oder das ursprüngliche Formular ein zweites Mal absenden? Die bekannte Browserwarnung zur erneuten Übermittlung von Daten macht ein architektonisches Detail sichtbar: Die Seite in der Adressleiste ist noch immer die Antwort auf eine zustandsverändernde Anfrage.

Post/Redirect/Get, meist als PRG abgekürzt, gibt dieser Interaktion eine klarere Form. Nachdem der Server einen POST angenommen hat, leitet er den Browser zu einer Ressource weiter, die mit GET abgerufen werden kann. Eine Aktualisierung wiederholt dann den Abruf und nicht das Absenden des Formulars. Das ist nützlich, doch das Versprechen ist enger, als die gelegentliche Formulierung „doppelte Übermittlungen verhindern“ vermuten lässt. PRG verändert die nächste Navigation des Browsers; es sorgt nicht dafür, dass der ursprüngliche Schreibvorgang nur einmal stattfindet.

Das Muster ist ein Gespräch aus drei Nachrichten

Betrachten wir ein Formular, das eine Notiz erstellt. Ohne Weiterleitung sendet der Browser POST /notes und erhält direkt die Erfolgsseite. Das aktuelle Dokument ist somit mit der POST-Antwort verbunden. Beim Neuladen kann es erforderlich sein, den POST-Body erneut zu senden.

Mit PRG besteht der Austausch aus drei Schritten:

  1. Der Browser sendet POST /notes mit den Formulardaten.
  2. Nachdem der Server die neue Notiz festgeschrieben hat, antwortet er mit 303 See Other und Location: /notes/42.
  3. Der Browser folgt diesem Ziel mit GET /notes/42 und stellt das Ergebnis dar.
POST /notes HTTP/1.1
Host: example.test
Content-Type: application/x-www-form-urlencoded

title=A%20small%20question

HTTP/1.1 303 See Other
Location: /notes/42
Content-Length: 0

GET /notes/42 HTTP/1.1
Host: example.test

Die letzte Seite repräsentiert nun einen GET. Sie kann normalerweise aktualisiert, verlinkt oder als Lesezeichen gespeichert werden, ohne den Erstellungsvorgang zu wiederholen. RFC 9110 definiert 303 ausdrücklich als Weiterleitung zu einer anderen Ressource, deren Repräsentation mit GET oder HEAD abgerufen werden kann. Die MDN-Referenz zu 303 zeigt denselben Anwendungsfall für Formulare.

Dies ist nicht nur eine kosmetische Änderung der URL. Das Muster trennt den Befehl von der Repräsentation seines Ergebnisses. Der POST sagt: „Versuche, diese Notiz zu erstellen.“ Der spätere GET sagt: „Zeige Notiz 42.“ Diese Anfragen haben unterschiedliche Zwecke und können sich bei Caching, Autorisierung und Fehlerbehandlung unterscheiden.

Warum 303 klarer ist als ein unbeabsichtigtes 302

Viele Anwendungen verwenden nach einem Formular 302 Found und scheinen damit zu funktionieren. Dieses Verhalten hat einen historischen Hintergrund: RFC 9110 erlaubt einem User Agent, beim Folgen einer 302-Antwort POST in GET zu ändern. Deshalb muss nicht jeder bestehende 302-Ablauf als fehlerhaft bezeichnet werden.

303 See Other drückt den beabsichtigten Übergang jedoch unmittelbar aus. Die nächste Anfrage ruft die ausgewählte Ressource ab, statt den ursprünglichen Vorgang zu wiederholen. Wer eine Route, einen Test oder einen HTTP-Trace liest, muss nicht erst ableiten, dass die Anwendung vom historischen POST-zu-GET-Verhalten von 302 abhängt.

307 Temporary Redirect bedeutet etwas anderes. Seine entscheidende Eigenschaft ist, dass der User Agent die Request-Methode beim Folgen der Weiterleitung nicht ändern darf. Ein mit 307 weitergeleiteter POST bleibt ein POST. Das ist sinnvoll, wenn ein Endpoint vorübergehend verschoben wurde und derselbe Vorgang eine andere URI erreichen muss, entspricht aber dem Gegenteil des üblichen PRG-Ziels. Der entsprechende permanente, methodenerhaltende Status ist 308.

Der Statuscode sollte daher der beabsichtigten Semantik folgen und nicht einer Regel, nach der eine Weiterleitung grundsätzlich besser wäre:

  • 303 eignet sich, wenn ein abgeschlossener POST zu einer GET-Repräsentation führen soll.
  • 307 oder 308 eignen sich, wenn die weitergeleitete Anfrage ihre Methode und ihren Body beibehalten muss.
  • 302 sollte als temporäre Weiterleitung mit historisch bedingtem Methodenwechsel verstanden werden, nicht als klarste Beschreibung von PRG.

Ein bewusst kleines PHP-Beispiel

Der folgende Handler veranschaulicht die Erfolgsgrenze. Er setzt voraus, dass Authentifizierung, Autorisierung, CSRF-Prüfung, Begrenzungen der Eingabelänge und die PDO-Verbindung bereits vorhanden sind. Außerdem wird angenommen, dass notes.id eine automatisch hochzählende Ganzzahl ist. Diese ausgelassenen Kontrollen gehören zur umgebenden Anwendung und sind keine optionalen Produktionsdetails.

<?php
declare(strict_types=1);

$title = trim((string) ($_POST['title'] ?? ''));

if ($title === '') {
    http_response_code(422);
    renderNoteForm(['title' => 'A title is required.'], $_POST);
    exit;
}

$statement = $pdo->prepare(
    'INSERT INTO notes (title) VALUES (:title)'
);
$statement->execute(['title' => $title]);

$noteId = (int) $pdo->lastInsertId();

header('Location: /notes/' . $noteId, true, 303);
exit;

Die Weiterleitung erfolgt erst, nachdem das Insert erfolgreich war. Schlägt die Validierung fehl, existiert noch keine Notiz, die angezeigt werden könnte. Der Handler stellt daher einen hilfreichen Fehler dar, statt einen abgeschlossenen Befehl vorzutäuschen. Löst der Datenbankschreibvorgang eine Exception aus, sollte die normale Fehlerbehandlung übernehmen; eine erfolgreiche Weiterleitung würde eine falsche Bestätigung erzeugen.

Die offizielle PHP-Dokumentation zu header() nennt drei relevante Details. Header müssen vor jeder Ausgabe vorgesehen werden, ein bloßer Location-Header erzeugt normalerweise 302, sofern nicht bereits ein passender Status gesetzt wurde, und das dritte Argument kann den Response-Code bestimmen. Das unmittelbare exit ist ebenso beabsichtigt: Redirect-Header beenden ein PHP-Skript nicht selbstständig.

Eine Erfolgsmeldung kann auf der GET-Seite direkt aus der erstellten Ressource abgeleitet oder als kurzlebiger „Flash“-Zustand in der Session transportiert werden. Flash-Zustand betrifft die Darstellung; der erstellte Datensatz und seine Kennung bleiben das dauerhafte Ergebnis. Eine Meldung, die beim Aktualisieren verschwindet, sollte nicht der einzige Beleg für einen erfolgreichen Vorgang sein.

Was PRG nicht verhindert

Angenommen, jemand klickt schnell genug doppelt auf den Submit-Button, um zwei POST-Anfragen zu senden. Beide können den Server erreichen, bevor eine der Antworten empfangen wurde. Somit konnte noch keine 303-Antwort die Browsernavigation beeinflussen. Zwei Tabs können dasselbe bewirken. Ein Skript kann die Weiterleitung ignorieren. Eine Verbindung kann abbrechen, nachdem der Server den Vorgang festgeschrieben hat, aber bevor der Client die Antwort erhält; der Client kennt das Ergebnis dann nicht sicher.

Keiner dieser Fälle widerspricht PRG. Sie treten an einer anderen Grenze auf. Das Muster steuert, was ein User Agent nach einer POST-Antwort tun soll; es verschmilzt nicht zwei POST-Anfragen zu einem Vorgang.

Diese Unterscheidung folgt dem HTTP-Modell. RFC 9110 definiert Idempotenz anhand der beabsichtigten Wirkung mehrerer identischer Anfragen. PUT und DELETE sind als idempotente Methoden definiert, POST hingegen nicht. Eine Anwendung kann einen bestimmten POST-Vorgang so gestalten, dass Wiederholungen sicher behandelt werden, doch eine anschließende Weiterleitung verleiht ihm diese Eigenschaft nicht.

Das Deaktivieren eines Submit-Buttons mit JavaScript kann unbeabsichtigte Doppelklicks reduzieren und mitteilen, dass die Verarbeitung läuft. Das ist nützliches Feedback in der Benutzeroberfläche, kann aber nicht die Korrektheitsgrenze bilden. JavaScript kann ausfallen, Anfragen können von einem anderen Client stammen, und zwei Anfragen können bereits unterwegs sein, bevor sich der Button ändert.

Die Duplikatgrenze gehört zur Geschäftsregel

Der Server braucht zunächst eine genaue Antwort auf die Frage: „In welchem Sinn ist etwas ein Duplikat?“ Zwei Newsletter-Anmeldungen für dieselbe Liste und E-Mail-Adresse können eine einzige Mitgliedschaft darstellen. Zwei Kommentare mit demselben Text können beabsichtigt sein. Zwei Zahlungsversuche benötigen einen stärkeren, vorgangsspezifischen Vertrag als nur den Vergleich ihrer Beträge.

Für unterschiedliche Regeln eignen sich unterschiedliche Kontrollen:

  • Ein eindeutiger Datenbank-Constraint passt zu einer natürlichen Invariante wie einer Mitgliedschaft pro (list_id, subscriber_id). MariaDB dokumentiert, dass ein UNIQUE-Constraint verlangt, dass der eingeschränkte Wert oder die Wertkombination nur einmal vorkommt, und Verletzungen zurückweist. Bei Nebenläufigkeit ist das stärker als eine Prüfung auf eine vorhandene Zeile mit einem späteren Insert als getrennte Schritte.
  • Ein Operation-Token oder Idempotency-Key passt zu einer Erstellung ohne natürlichen eindeutigen Wert. Das Formular erhält eine nicht vorhersagbare Vorgangskennung, und der Server speichert sie zusammen mit dem Ergebnis. Eine erneute Verwendung gibt das ursprüngliche Ergebnis zurück oder verweist darauf, statt die Wirkung erneut anzuwenden. Das Token benötigt einen definierten Geltungsbereich und eine definierte Lebensdauer. Es muss atomar mit dem Schreibvorgang beansprucht werden, denn eine getrennte Abfrage „habe ich das schon gesehen?“ kann selbst ein Race verursachen.
  • Eine Transaktion hält den angenommenen Vorgang und seine Duplikatmarkierung zusammen, wenn mehrere Datenbankänderungen eine Entscheidung bilden. Externe Nebenwirkungen werden dadurch nicht „exactly once“. E-Mails, Payment-APIs und Queues benötigen weiterhin eigene Verträge für Wiederholungen und Abgleich.

Diese Kontrollen ergänzen PRG. Der Server schützt den Vorgang, während die Weiterleitung dem Browser eine stabile Ergebnisseite gibt. Das eine ist eine Grenze für Datenintegrität, das andere eine Navigationsgrenze.

CSRF-Tokens beantworten eine andere Frage

Ein CSRF-Token wird gelegentlich mit einem allgemeinen Einmal-Token für Übermittlungen verwechselt. Sein Sicherheitszweck ist ein anderer: Es hilft einer mit Cookies authentifizierten Anwendung, eine zustandsverändernde Anfrage aus einem nicht vertrauenswürdigen Kontext zurückzuweisen. Die CSRF-Empfehlungen von OWASP raten zur serverseitigen Prüfung von CSRF-Schutzmaßnahmen für zustandsverändernde Anfragen und warnen davor, GET für Zustandsänderungen zu verwenden.

Ein gültiges CSRF-Token beweist nicht, dass derselbe autorisierte Benutzer den Vorgang nicht zweimal übermittelt hat. Umgekehrt beweist ein Schlüssel zur Duplikaterkennung nicht, dass die Anfrage aus einem erlaubten Origin oder einer erlaubten Benutzerinteraktion stammt. Die begriffliche Trennung erleichtert Reviews, selbst wenn eine Anwendung beide Zustände in derselben Session oder demselben Formular speichert.

Sequenz und Race testen

Ein einzelner Klick im Browser reicht nicht aus, um PRG zu verifizieren. Der Netzwerkverkehr sollte untersucht oder eine sichere Testumgebung verwendet werden, um mindestens folgende Fälle zu prüfen:

  • Ein gültiger POST liefert 303 mit dem erwarteten vertrauenswürdigen Location-Wert.
  • Die Weiterleitung wird mit GET verfolgt, und eine Aktualisierung des Ergebnisses sendet keinen weiteren POST.
  • Ungültige Eingaben erzeugen keinen Datensatz und erhalten hilfreiche Formularfehler.
  • Ein fehlgeschlagener Schreibvorgang sendet keine Erfolgsweiterleitung.
  • Zwei nebenläufige Anfragen für denselben geschützten Vorgang führen zu einem angenommenen Geschäftsergebnis.
  • Die erneute Verwendung eines Operation-Keys liefert ein definiertes Ergebnis, statt den Schreibvorgang unbemerkt erneut anzuwenden.
  • Fehlende oder ungültige CSRF-Nachweise werden unabhängig von der Duplikatbehandlung zurückgewiesen.

Der nebenläufige Fall ist am wichtigsten. Zwei aufeinanderfolgende Klicks, nachdem jede Seite vollständig geladen wurde, testen nicht das Race, das ein Storage-Constraint oder das atomare Beanspruchen eines Tokens lösen soll.

Fazit

Post/Redirect/Get löst ein reales, aber spezifisches Problem. Das Muster macht die Seite nach einem erfolgreichen Formular-POST zu einer GET-Ressource, sodass gewöhnliches Aktualisieren und Setzen eines Lesezeichens nicht mehr auf die zustandsverändernde Anfrage verweisen. Eine 303-Antwort drückt diesen Übergang ausdrücklich aus, und PHP kann sie mit einer kleinen, verständlichen Antwort senden.

Die ehrliche Grenze ist ebenso wichtig wie das Muster. PRG kann zwei bereits vorhandene POST-Anfragen nicht stoppen, nicht bestimmen, was als derselbe Geschäftsvorgang gilt, und nicht die Absicht eines Benutzers authentifizieren. Diese Aufgaben gehören zu Anwendungsregeln, Datenbank-Constraints oder Operation-Keys, gegebenenfalls Transaktionen und CSRF-Schutzmaßnahmen. Ein robuster Formularablauf kombiniert diese Ebenen: Der Schreibvorgang wird auf dem Server geschützt, anschließend wird der Browser zu einer Repräsentation weitergeleitet, die sicher erneut besucht werden kann.

References