mitmario.dev

Bearbeiten und löschen

PHP Sandbox 4 Min Lesezeit 2 BeispieleLektion 5 von 7

Bis hierher gab es genau eine Frage an jede geschützte Seite: Ist jemand angemeldet? Sie hat gereicht, weil jeder nur seine eigene Liste gesehen hat.

Sobald eine Adresse eine Nummer enthält, reicht sie nicht mehr. /lesezeichen/2/bearbeiten steht in der Adresszeile, und die 2 kommt vom Besucher. Angemeldet zu sein macht daraus kein Recht, diesen einen Eintrag anzufassen.

Wem gehört dieser Eintrag

Drei Adressen, drei Antworten
<?php

// Drei Adressen, drei Antworten. Klick sie im Reiter Browser durch und
// sieh dir im Reiter Netzwerk an, welcher Statuscode zurueckkommt.

$eintraege = [
    1 => ["besitzer" => "mia", "titel" => "Astro"],
    2 => ["besitzer" => "tom", "titel" => "Toms Werkstatt"],
];

$angemeldetAls = "mia";

$pfad = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
$treffer = [];

if (preg_match("#^/eintrag/([0-9]+)$#", $pfad, $treffer) !== 1) {
    $inhalt = '<p>Waehl einen aus:</p>
        <ul>
        <li><a href="/eintrag/1">/eintrag/1</a>, der gehoert mia</li>
        <li><a href="/eintrag/2">/eintrag/2</a>, der gehoert tom</li>
        <li><a href="/eintrag/9">/eintrag/9</a>, den gibt es nicht</li>
        </ul>';
} else {
    $id = (int) $treffer[1];
    $eintrag = $eintraege[$id] ?? null;

    if ($eintrag === null) {
        // Es gibt ihn nicht. Mehr sagt diese Antwort nicht.
        http_response_code(404);
        $inhalt = "<p><strong>404</strong> Diesen Eintrag gibt es nicht.</p>";
    } elseif ($eintrag["besitzer"] !== $angemeldetAls) {
        // Es gibt ihn, aber nicht fuer dich. Damit verraet die Antwort, dass
        // es ihn gibt. Wem das zu viel ist, der antwortet auch hier mit 404.
        http_response_code(403);
        $inhalt = "<p><strong>403</strong> Den gibt es, aber er gehoert nicht dir.</p>";
    } else {
        $inhalt = "<p><strong>200</strong> " . htmlspecialchars($eintrag["titel"]) . ", und er gehoert dir.</p>";
    }

    $inhalt .= '<p><a href="/">Zurueck</a></p>';
}

?>
<!doctype html>
<html lang="de">
<head><meta charset="utf-8"><title>403 oder 404</title>
<style>body{font-family:system-ui,sans-serif;max-width:34rem;margin:2rem auto}</style></head>
<body>
<h1>Angemeldet als <?= htmlspecialchars($angemeldetAls) ?></h1>
<?= $inhalt ?>
</body>
</html>

Diese Lücke hat einen Namen, sie heißt unsichere direkte Objektreferenz. Der Name ist sperrig, der Fehler ist banal: Der Code holt sich den Eintrag zur Nummer aus der Adresse und zeigt ihn an, weil ja jemand angemeldet ist. Dass die Nummer zu einem fremden Eintrag gehört, hat niemand gefragt.

Gefunden wird so etwas nicht durch Nachdenken, sondern durch Hochzählen. Wer /lesezeichen/1 sieht, tippt irgendwann /lesezeichen/2. Das erste Beispiel lässt dich genau das tun: Klick im Reiter Browser die drei Adressen durch, angemeldet bist du als mia, und im Reiter Netzwerk steht zu jeder Adresse ihr Statuscode.

Die Antwort darauf ist eine Funktion und keine Regel. Wenn drei Stellen dieselbe Prüfung machen müssen, machen es irgendwann zwei. In der Aufgabe schreibst du deshalb eine Funktion, die den Eintrag zurückgibt oder gar nicht zurückkehrt, und alle drei Stellen rufen sie auf. Damit ist die Reihenfolge nicht mehr eine Frage der Sorgfalt, sondern des Aufrufs.

Die Reihenfolge in dieser Funktion ist übrigens selbst eine Entscheidung: Erst kommt die Anmeldung, dann die Suche. Ein Gast bekommt so die Anmeldeseite und keine Fehlermeldung, und er erfährt auch nicht nebenbei, welche Nummern es gibt.

403 oder 404

Zwei Fälle, zwei Antworten, und das ist keine Kosmetik:

  • 404 heißt: Den gibt es nicht. Mehr sagt die Antwort nicht, und mehr weiß der Fragende danach auch nicht.
  • 403 heißt: Den gibt es, aber nicht für dich. Die Antwort ist ehrlicher und verrät genau dadurch etwas: dass die Nummer belegt ist.

Für ein Lesezeichen ist das harmlos, und die klarere Meldung ist mehr wert. Es gibt Anwendungen, in denen es anders liegt. Wer eine Plattform baut, auf der Konten fortlaufend nummeriert sind, verrät mit jedem 403 die Größe seines Kundenstamms. Solche Anwendungen antworten auch dann mit 404, wenn sie es besser wissen. Beides ist vertretbar, nur nicht beides gleichzeitig und ohne Grund.

Das Token, und der Fall, an den man nicht denkt

hash_equals und die leere Sitzung
<?php

declare(strict_types=1);

// hash_equals vergleicht zwei Zeichenketten so, dass die Dauer nichts ueber
// den Inhalt verraet. Es beantwortet aber nur die Frage "sind die beiden
// gleich", und die hat einen Fall, an den man nicht denkt.

$echtes = bin2hex(random_bytes(16));

$faelle = [
    ["Sitzung hat ein Token, Formular schickt dasselbe", $echtes, $echtes],
    ["Sitzung hat ein Token, Formular schickt ein anderes", $echtes, bin2hex(random_bytes(16))],
    ["Sitzung hat ein Token, Formular schickt keines", $echtes, ""],
    ["Sitzung hat keines, Formular schickt keines", "", ""],
];

foreach ($faelle as [$was, $ausDerSitzung, $ausDemFormular]) {
    echo str_pad($was, 54), hash_equals($ausDerSitzung, $ausDemFormular) ? "durch" : "abgewiesen", "\n";
}

echo "\nDer letzte Fall ist der gefaehrliche: Zwei leere Zeichenketten sind\n",
    "gleich, und damit kaeme eine Anfrage durch, bei der niemand je ein\n",
    "Token gesehen hat. Deshalb steht vor dem Vergleich eine zweite Frage:\n\n";

foreach ($faelle as [$was, $ausDerSitzung, $ausDemFormular]) {
    $inOrdnung = $ausDerSitzung !== "" && hash_equals($ausDerSitzung, $ausDemFormular);

    echo str_pad($was, 54), $inOrdnung ? "durch" : "abgewiesen", "\n";
}

// Und die Frage, die immer kommt: warum nicht einfach ===?
echo "\n=== gibt dasselbe Ergebnis: ", $echtes === $echtes ? "durch" : "abgewiesen", "\n";
echo "Der Unterschied liegt in der Dauer. === hoert beim ersten\n",
    "abweichenden Zeichen auf, hash_equals sieht sich immer alles an.\n";

Ändern und Löschen sind POST-Anfragen, die etwas anstellen. Damit sind sie genau das, was ein CSRF-Angriff braucht: Eine fremde Seite baut ein Formular auf deine Adresse, jemand klickt darauf, und der Browser schickt die Sitzungs-Cookies brav mit. Das ist Lektion 15.3, und die Antwort war ein Token, das nur auf deiner eigenen Seite steht.

Das zweite Beispiel führt den Vergleich vor, und der letzte Fall ist der, der einen Schutz still aushebelt: hash_equals("", "") ist wahr. Wenn also in der Sitzung noch kein Token steht und im Formular auch keines, kommt die Anfrage durch. Genau das passiert bei einer frischen Sitzung, und ein Angreifer braucht dafür nichts weiter zu tun, als kein Token zu schicken.

Deshalb steht vor dem Vergleich eine zweite Frage: Gibt es überhaupt eines? Zwei Zeilen statt einer, und ohne sie ist der ganze Schutz eine Verzierung.

Warum trotzdem hash_equals und nicht ===? Das Ergebnis ist dasselbe. Der Unterschied liegt in der Dauer: === hört beim ersten abweichenden Zeichen auf, hash_equals sieht sich immer alles an. Bei einem Wert, den jemand zu erraten versucht, ist die Antwortzeit eine Auskunft, und die will man nicht geben.

In der Liste steht neben jedem Eintrag ein Knopf und kein Link, und das hat zwei Gründe.

Der erste ist die Regel: Ein GET verändert nichts. Daran halten sich nicht nur Höflichkeit und Standard, sondern auch Maschinen. Ein Suchmaschinen-Crawler folgt Links, ein Browser lädt sie vor, ein Virenscanner im Firmennetz öffnet sie. Wer sein Löschen hinter einen Link legt, findet eines Tages eine leere Liste und keinen Schuldigen.

Der zweite ist praktisch: An einen Link bekommt man kein Token. Es müsste in die Adresse, und damit stünde es im Verlauf des Browsers, im Protokoll des Servers und im Referer jeder Seite, auf die man danach klickt. Ein verstecktes Feld in einem Formular hat keines dieser Probleme.

Deshalb ist der Löschknopf in ansichten/liste.php ein winziges Formular: ein verstecktes Feld mit dem Token und ein Knopf. Es sieht aus wie ein Link und ist keiner.

Zum Mitnehmen

Anlegen kann jeder für sich. Ändern und Löschen betreffen etwas, das schon jemandem gehört, und ab da reicht die Frage nicht mehr, ob jemand angemeldet ist. Die Frage lautet, ob er der Richtige ist.

Jetzt du

Basis Konto, kostenlos

Zu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.

Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.

In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.

Was in dieser Lektion steckt

  • Artikel mit 2 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe, dein Code läuft auf einem Server

    Öffnet sich mit dem Basis Konto.