Abschnitt 15 · Lektion 1
Eingaben sind nie vertrauenswürdig
Der ganze Abschnitt hängt an einem einzigen Satz, und der klingt harmloser, als er ist: Eine Anfrage ist kein Bericht darüber, was passiert ist. Sie ist ein Vorschlag darüber, was passieren soll. Wer sie schickt, hat sie geschrieben, und er hat jedes Byte darin selbst gewählt.
Die Liste ist länger, als die meisten denken
Frag jemanden, was an einer Anfrage vom Aufrufer bestimmt wird, und du bekommst „die Formularfelder” zu hören. Das ist ungefähr ein Fünftel der Wahrheit.
import express from "express";
const app = express();
app.use(express.urlencoded({ extended: true }));
app.all("/konto/47", (req, res) => {
// Nichts davon hat der Server bestimmt. Alles kam mit der Anfrage,
// und deshalb steht hier alles in der Antwort.
res.type("text/plain").send(
[
`Methode ${req.method}`,
`Pfad ${req.path}`,
`Query ${JSON.stringify(req.query)}`,
`Content-Type ${req.headers["content-type"]}`,
`Cookie ${req.headers.cookie}`,
`Referer ${req.headers.referer}`,
`User-Agent ${req.headers["user-agent"]}`,
`X-Rolle ${req.headers["x-rolle"]}`,
`Körper ${JSON.stringify(req.body)}`,
].join("\n") + "\n"
);
});
app.listen(3000); Rechts steht, was in dieser einen Anfrage alles vom Aufrufer kam. Häng etwas an die Adresse, schick eine eigene Kopfzeile mit, und die Liste ändert sich:
curl "http://localhost:3000/konto/47?rolle=admin" -H "X-Rolle: chef" -H "Cookie: sid=beliebig"
Das Beispiel schickt eine einzige Anfrage und lässt den Server auflisten, was daran angekommen ist.
Neun Zeilen, und keine davon hat der Server bestimmt: die Methode, der Pfad, jeder
Query-Parameter, der Content-Type, das Cookie, der Referer, der User-Agent, eine frei
erfundene Kopfzeile namens X-Rolle und der Körper. Dazu kommen noch die Länge und die
Reihenfolge. Nichts davon ist geprüft, nur weil es angekommen ist.
Drei Zeilen daraus sind die häufigsten Irrtümer im Alltag:
- Der
Refererist kein Nachweis, dass jemand wirklich von deiner Seite kommt. Er ist ein Textfeld, das der Aufrufer füllt, und im Beispiel steht darin eine Adresse, die es gar nicht gibt. - Der
User-Agentist keine Auskunft darüber, welches Programm anfragt. Er sagt nur, was das Programm behauptet. - Ein Cookie ist kein Ausweis, solange der Server ihn nicht selbst ausgestellt hat. Hier steht
sid=ich-bin-die-chefindarin, und das ist genau so viel wert, wie es aussieht.
Dahinter steckt fast immer derselbe Denkfehler: „Das schickt ja mein Frontend so.” Stimmt, dein
Frontend schickt es so. Nur steht zwischen deinem Frontend und deinem Server nichts, was das
erzwingt. Ein curl-Aufruf, die Entwicklerwerkzeuge des Browsers oder ein zwanzig Zeilen langes
Skript kommen an derselben Stelle an, ohne dein Formular je gesehen zu haben. Dein Frontend ist eine
Bequemlichkeit für ehrliche Nutzer, keine Schranke.
Aufzählen, was erlaubt ist
Die Gegenmaßnahme ist weniger eine Technik als eine Haltung, und sie passt in einen Satz: Zähl auf, was erlaubt ist, statt zu raten, was verboten gehört.
// Zwei Wege, einen Formatwunsch zu prüfen. Der eine zählt auf, was
// verboten ist, der andere, was erlaubt ist.
const VERBOTEN = [".exe", ".sh", ".bat"];
const ERLAUBT = ["text", "csv"];
function mitBlocklist(wunsch) {
return VERBOTEN.some((endung) => wunsch.endsWith(endung)) ? "abgewiesen" : `angenommen: ${wunsch}`;
}
function mitAllowlist(wunsch) {
return ERLAUBT.includes(wunsch) ? `angenommen: ${wunsch}` : "abgewiesen";
}
for (const wunsch of ["csv", "bericht.exe", "bericht.EXE", "bericht.exe.", "bericht.cmd", "../../etc/passwd"]) {
console.log(`${wunsch.padEnd(20)} Blocklist: ${mitBlocklist(wunsch).padEnd(28)} Allowlist: ${mitAllowlist(wunsch)}`);
} Links steht die Blocklist mit drei verbotenen Endungen, rechts die Allowlist mit zwei erlaubten Werten. Die Blocklist fängt genau einen der fünf Angriffe, und die vier anderen sind keine Kunststücke: Großschreibung, ein Punkt am Ende, eine Endung, an die niemand gedacht hat, und ein Pfad, der gar keine Datei im eigenen Ordner meint. Für jeden dieser Fälle kannst du eine Regel nachschieben, und beim nächsten Mal fällt jemandem der übernächste Fall ein. Das Spiel ist nicht zu gewinnen, weil du gegen eine unendliche Menge antrittst.
Die Allowlist tritt gegen zwei Werte an. Sie weist alles ab, was sie nicht kennt, auch das, was du nie bedacht hast, und genau das ist ihr Vorteil. Wenn du sie später erweitern musst, merkst du das sofort, weil etwas Erlaubtes abgewiesen wird. Bei einer Blocklist merkst du gar nichts.
Dieselbe Regel gilt im Übrigen nicht nur für Werte, sondern auch für Felder. Wer einen ganzen
Anfragekörper in ein Datenbankobjekt kippt, hat gerade jedem Aufrufer erlaubt, ein Feld
mitzuschicken, an das niemand gedacht hat. rolle, zum Beispiel.
Eine Zahl ist erst eine Zahl, wenn du sie geprüft hast
Query-Parameter kommen als Text an. Aus Text wird schnell eine Zahl, und dabei geht mehr schief, als es aussieht.
import express from "express";
const app = express();
app.get("/seite", (req, res) => {
const roh = req.query.seite;
const zahl = Number(roh ?? 1);
const geprueft = Number.isInteger(zahl) && zahl >= 1 && zahl <= 3;
console.log(
`${String(JSON.stringify(roh)).padEnd(14)} Number() ergibt ${String(zahl).padEnd(9)} geprüft: ${geprueft}`
);
res.end();
});
app.listen(3000); Acht Aufrufe, und Number() allein rettet dich in keinem einzigen der sechs schlechten Fälle.
Eine negative Zahl bleibt eine Zahl. "abc" wird zu NaN, und NaN ist in jedem Vergleich falsch,
was Prüfungen still durchrutschen lässt. "2.5" ist eine gültige Zahl und trotzdem keine
Seitennummer. "1e3" ist tausend, geschrieben von jemandem, der etwas vorhat. Ein leerer Wert
wird zu 0, nicht zu NaN, und das ist die unangenehmste Zeile der Tabelle. Und ganz unten steht
der Fall, den fast niemand auf dem Zettel hat: Derselbe Parameter zweimal in der Adresse ergibt in
Express ein Array, kein einzelnes Feld. Wer darauf .trim() oder .toLowerCase() aufruft,
bekommt keinen Angriff, sondern einen Absturz.
Die Prüfung, die alle acht Zeilen richtig einordnet, ist die letzte Spalte:
Number.isInteger(zahl) && zahl >= 1 && zahl <= SEITEN. Sie fragt nicht „ist das irgendwie eine
Zahl”, sondern „ist das eine der Zahlen, die hier Sinn ergeben”. Das ist wieder dieselbe Haltung,
nur mit Zahlen statt mit Wörtern.
Wo diese Regel im Alltag steht
Prüfen an der Systemgrenze, nicht mittendrin. Die Systemgrenze ist die Stelle, an der etwas von außen hereinkommt, und ab dort verlässt du dich auf deine eigenen Werte statt auf die des Aufrufers. Genau so ist diese Seite hier gebaut: Jede externe Eingabe wird an der Grenze einmal geprüft, und danach vertraut der Code seinen eigenen Zusagen und prüft nicht zum zweiten Mal.
Das ist auch der Grund, warum die Reihenfolge in der Challenge zählt. Erst die Frage „wer bist du”, dann „darfst du das”, dann „ergibt das, was du willst, überhaupt Sinn”. Wer die Reihenfolge dreht, prüft Zahlen für jemanden, den er noch gar nicht kennt.
Zum Mitnehmen
Zwischen deinem Formular und deinem Server steht nichts, was irgendetwas erzwingt. Jede Angabe, die ankommt, hat der Aufrufer geschrieben, und er durfte dabei schreiben, was er wollte.
Jetzt du
Basis Konto, kostenlosZu 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 3 Beispielen zum Ausprobieren
Steht hier, ohne Konto lesbar.
-
Aufgabe, dein Code läuft auf einem Server
Öffnet sich mit dem Basis Konto.