mitmario.dev

Eingaben sind nie vertrauenswürdig

Node.js Sandbox 4 Min Lesezeit 3 BeispieleLektion 1 von 8

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.

Alles, was der Aufrufer bestimmt
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 Referer ist 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-Agent ist 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-chefin darin, 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.

Blocklist gegen Allowlist
// 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.

Die Zahl, die keine ist
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, 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 3 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe, dein Code läuft auf einem Server

    Öffnet sich mit dem Basis Konto.