Webentwicklung

JSON-Request-Bodys in PHP - Medientyp, Bytes und Struktur getrennt validieren

JSON-Request-Bodys in PHP - Medientyp, Bytes und Struktur getrennt validieren

Ein PHP-Endpunkt empfängt Bytes, keine automatisch vertrauenswürdigen Anwendungsdaten. Wenn diese Bytes JSON enthalten, ist ein Aufruf von json_decode() notwendig. Eine erfolgreiche Dekodierung beantwortet jedoch nur eine Frage: Entspricht die Eingabe einer Syntax, die der Decoder akzeptiert? Sie beweist nicht, dass der Client einen vom Endpunkt unterstützten Medientyp gesendet hat, dass der Body eine vertretbare Größe hat, dass der oberste Wert ein Objekt ist oder dass ein Feld für die Operation sinnvoll ist.

Ein brauchbares Design lautet deshalb nicht „dekodieren und hoffen“. Sinnvoller ist eine Folge enger abgegrenzter Entscheidungen. Jede abgelehnte Anfrage sollte an genau der Grenze scheitern, die sie tatsächlich überschritten hat. Dadurch lässt sich der Endpunkt leichter nachvollziehen und Clients erhalten einen stabileren Vertrag.

Eine Anfrage überschreitet mehrere Grenzen

Fünf Ebenen sollten getrennt werden, damit sie nicht in einem unscharfen Zweig für „ungültige Eingabe“ zusammenfallen:

  1. Medientyp: Hat der Client den Inhalt als ein Format gekennzeichnet, das dieser Endpunkt unterstützt?
  2. Bytes: Kann der Server diese Inhaltsmenge annehmen und lesen?
  3. Syntax: Sind die Bytes gültiges JSON in einer vom Decoder akzeptierten Kodierung?
  4. Struktur: Besitzt der dekodierte Wert das Objekt, die Felder und die PHP-Typen, die dieser Endpunkt verlangt?
  5. Bedeutung: Ist die angeforderte Operation im aktuellen Geschäfts- und Autorisierungskontext erlaubt?

Das Bestehen einer Ebene sagt wenig über die nächste aus. Der JSON-Text true ist beispielsweise syntaktisch gültig. Das gilt ebenso für null, einen String und ein Array. RFC 8259 definiert einen JSON-Wert breit; ein Endpunkt, der ein Objekt erwartet, muss diese engere Regel selbst festlegen.

Zuerst den Content-Type der Anfrage prüfen

Content-Type beschreibt, wie der Inhalt interpretiert werden soll. Er ist nicht mit Accept austauschbar, das Präferenzen für eine Antwortrepräsentation ausdrückt. Akzeptiert ein Endpunkt laut Vertrag nur JSON-Request-Bodys, kann eine Nicht-JSON-Anfrage mit 415 Unsupported Media Type abgelehnt werden. Sowohl die Content-Type-Definition in RFC 9110 als auch das OWASP REST Security Cheat Sheet stützen die Validierung dieser Grenze.

Ein Medientyp kann Parameter enthalten. Daher ist ein Vergleich des vollständigen Header-Strings mit application/json unnötig fragil. Medientypnamen unterscheiden nicht zwischen Groß- und Kleinschreibung. Das folgende Beispiel nimmt den Wert vor dem ersten Semikolon, entfernt umgebenden Whitespace und vergleicht die kleingeschriebene Form. Es akzeptiert bewusst ausschließlich application/json. Ob Herstellertypen wie application/example+json akzeptiert werden, ist eine Entscheidung des API-Vertrags und keine Suffixprüfung, die jeder Endpunkt stillschweigend ergänzen sollte.

Ein fehlender Content-Type gilt hier als nicht unterstützt, weil diese konkrete Operation einen JSON-Body verlangt. Das bedeutet nicht, dass jede HTTP-Anfrage ohne Body diesen Header benötigt.

Die früheste Größengrenze außerhalb von PHP setzen

HTTP definiert 413 Content Too Large für Inhalte, die größer sind, als der Server verarbeiten will oder kann. Der passende Wert hängt von der Operation ab: Ein kurzes Einstellungsobjekt und ein Batch-Import sollten nicht allein aus Bequemlichkeit dieselbe Grenze erhalten.

Eine Anwendung kann strlen($raw) prüfen, aber erst nachdem der Body PHP erreicht hat und in einen String gelesen wurde. Diese Prüfung setzt den Anwendungsvertrag durch; sie ist keine vollständige Grenze gegen Speicherverbrauch oder Denial of Service. Ein Webserver, Reverse Proxy oder eine vorgelagerte Request-Schicht sollte eine frühere, zur Bereitstellung passende Grenze durchsetzen. Die Anwendungsprüfung bleibt sinnvoll, weil sie die Annahme des Endpunkts sichtbar macht und ihn weiterhin schützt, falls sich die Upstream-Konfiguration ändert.

Content-Length kann der Infrastruktur helfen, eine Anfrage einzurahmen oder abzulehnen. Die Anwendungsvalidierung sollte diesen Header allein jedoch nicht als Beweis für die letztlich empfangenen Bytes behandeln. Grenzen sollten dort gelten, wo Inhalt angenommen wird; der vollständige abgelehnte Body sollte nicht protokolliert werden.

Rohinhalt lesen und anschließend explizit dekodieren

Für einen nativen PHP-Endpunkt stellt php://input einen schreibgeschützten Stream für die Rohdaten des Request-Bodys bereit. Das Superglobal $_POST ist nicht die Schnittstelle für einen rohen JSON-Body. Durch das Lesen des Streams wird der Übergang vom HTTP-Inhalt zum Anwendungs-String sichtbar.

Auch der Decoder braucht eine explizite Richtlinie. PHP dokumentiert, dass json_decode() UTF-8-Eingaben akzeptiert, eine maximale Verschachtelungstiefe entgegennimmt und JSON_THROW_ON_ERROR unterstützt. RFC 8259 verlangt ebenfalls UTF-8 für über Netzwerke ausgetauschtes JSON. Mit dem Exception-Flag wird ein gültiges JSON-null nicht mehr mit dem standardmäßigen Fehlerwert null verwechselt. PHP wirft bei Dekodierungsfehlern eine JsonException, wenn dieses Flag aktiv ist.

Das Beispiel fängt diese Exception ab und gibt dem Client eine einzige zurückhaltende Meldung. Intern können fehlerhafte Syntax, ungültiges UTF-8 und zu tiefe Verschachtelung bei Bedarf als unterschiedliche Diagnosekategorien protokolliert werden. Für eine kleine öffentliche API ist es jedoch selten nötig, Parserdetails offenzulegen.

Ein kompaktes PHP-Beispiel

<?php

declare(strict_types=1);

function respondWithError(int $status, string $detail): never
{
    http_response_code($status);
    header('Content-Type: application/json');
    echo json_encode(['error' => $detail], JSON_THROW_ON_ERROR);
    exit;
}

$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
$mediaType = strtolower(trim(explode(';', $contentType, 2)[0]));

if ($mediaType !== 'application/json') {
    respondWithError(415, 'This endpoint accepts application/json.');
}

$raw = file_get_contents('php://input');
if ($raw === false) {
    throw new RuntimeException('Could not read the request body.');
}

$maxBytes = 64 * 1024; // Example policy, not a universal limit.
if (strlen($raw) > $maxBytes) {
    respondWithError(413, 'The request body is too large.');
}

if ($raw === '') {
    respondWithError(400, 'A JSON request body is required.');
}

try {
    $data = json_decode(
        $raw,
        associative: false,
        depth: 64,
        flags: JSON_THROW_ON_ERROR | JSON_BIGINT_AS_STRING
    );
} catch (JsonException) {
    respondWithError(400, 'The request body is not valid JSON.');
}

if (!$data instanceof stdClass) {
    respondWithError(422, 'The top-level JSON value must be an object.');
}

$fields = get_object_vars($data);
if (!array_key_exists('title', $fields) || !is_string($fields['title'])) {
    respondWithError(422, 'The title field must be a string.');
}

$title = trim($fields['title']);
if ($title === '' || mb_strlen($title) > 160) {
    respondWithError(422, 'The title must contain 1 to 160 characters.');
}

// Authentication, authorization, persistence, and conflict checks belong here.
http_response_code(204);

Die Body-Grenze von 64 KiB und die Verschachtelungstiefe von 64 sind Beispielrichtlinien, keine universell sicheren Werte. Sie sollten anhand des erwarteten Dokuments und der Bereitstellungsgrenzen des Endpunkts gewählt werden. Auch der Response-Helper ist bewusst minimal: Eine API, die bereits Problem Details verwendet, kann diese Verarbeitungsreihenfolge beibehalten und zugleich ihr etabliertes Fehlerformat zurückgeben.

Syntax und Struktur brauchen getrennte Entscheidungen

Das Dekodieren in ein Objekt bewahrt eine wichtige Unterscheidung auf der obersten Ebene. Wenn die Operation ein JSON-Objekt verlangt, sollten ein Array oder Skalar nicht durch PHP-Typumwandlung in die Feldvalidierung gelangen. Nach der Objektprüfung legt get_object_vars() die Felder offen, ohne vorzugeben, sie seien bereits ein vertrauenswürdiges Domänenobjekt.

Das Beispiel unterscheidet fehlerhaftes JSON mit 400 Bad Request von gültigem JSON, das nicht zur verlangten Anwendungsstruktur passt, mit 422 Unprocessable Content. RFC 9110 beschreibt 422 als Inhalt mit korrekter Syntax, dessen enthaltene Anweisungen nicht verarbeitet werden können. Das ist eine schlüssige Zuordnung, nicht die einzig mögliche API-Konvention. Entscheidend ist, eine Richtlinie zu dokumentieren und konsistent anzuwenden.

Die Feldvalidierung sollte außerdem zwischen „fehlt“ und „vorhanden, aber mit falschem Typ“ unterscheiden. array_key_exists() macht diese Entscheidung explizit. Anschließend wird ein String getrimmt und gegen die Längenregel der Operation geprüft. Escaping fehlt bewusst: Ausgabekodierung gehört in den späteren Ausgabekontext, während Datenbankwerte in parametrisierte Abfragen gehören. Eingaben so zu verändern, dass sie lediglich sicherer aussehen, kann einen Vertragsfehler verdecken.

Details, die ein kleines Beispiel nicht lösen kann

Doppelte Objektnamen

RFC 8259 sagt, dass Namen von Objektmitgliedern eindeutig sein sollten, und warnt vor unterschiedlichem Empfängerverhalten bei Duplikaten. Gewöhnliches json_decode() ist kein Validator für doppelte Namen. Wenn deren Erkennung für Signaturen, Audits oder ein striktes Protokoll wichtig ist, ist ein Parsing-Ansatz erforderlich, der Duplikate erkennt, bevor Mitglieder in einem PHP-Objekt zusammenfallen.

Zahlen und Bezeichner

Das Beispiel verwendet JSON_BIGINT_AS_STRING, damit ausreichend große Integer-Tokens nicht in einen ungenauen Gleitkommawert umgewandelt werden. Dieses Flag ist keine vollständige Zahlenrichtlinie. Geldbeträge, Messwerte, Bereiche und Berechnungen benötigen weiterhin eine domänenspezifische Behandlung. Werte wie Konto-IDs lassen sich oft besser als JSON-Strings definieren, wenn sie Bezeichner und keine Mengen sind.

Unbekannte Felder

Ob unbekannte Mitglieder abgelehnt werden, ist eine Kompatibilitätsentscheidung. Eine Ablehnung entdeckt Tippfehler früh; Ignorieren kann additive Weiterentwicklung erleichtern. Beide Richtlinien können sinnvoll sein. Unbeabsichtigte Inkonsistenz zwischen Endpunkten ist für Clients jedoch schwieriger als jede dokumentierte Variante.

Autorisierung und Zustand

Auch ein perfekt strukturiertes Objekt kann eine Operation verlangen, die der Aufrufer nicht ausführen darf, auf eine nicht mehr vorhandene Ressource verweisen oder mit dem aktuellen Zustand kollidieren. Parsing und Validierung ersetzen weder Authentifizierung, Autorisierung, Nebenläufigkeitskontrollen noch Datenbank-Constraints. Sie liefern lediglich sicherere Eingaben für diese späteren Entscheidungen.

Eine mehrschichtige Checkliste

  • Festlegen, welche Request-Medientypen der Endpunkt akzeptiert.
  • Eine frühe Body-Größengrenze in der Web- oder Proxy-Schicht und eine passende Anwendungsrichtlinie anwenden.
  • Den Roh-Body aus php://input lesen.
  • Mit expliziter Tiefe und eindeutiger Fehlerbehandlung dekodieren.
  • Den vorgesehenen obersten JSON-Typ verlangen.
  • Vorhandensein, PHP-Typ, Bereich, Länge und Format von Feldern ohne stille Typumwandlung validieren.
  • Den Umgang mit unbekannten Feldern, doppelten Namen und großen Zahlen festlegen.
  • Erst danach Autorisierung, Anwendungszustand und Persistenz bewerten.

Das Ziel ist nicht, einen kleinen Endpunkt in eine Zeremonie zu verwandeln. Es geht darum, nicht von einer Funktion Antworten auf fünf verschiedene Fragen zu erwarten. Wenn Medientyp, Bytes, Syntax, Struktur und Bedeutung eigene Grenzen besitzen, lassen sich Fehler leichter erklären und Änderungen leichter prüfen.

References