mitmario.dev

Synthese: die abgesicherte API

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

Die Bücher-API begleitet dich seit Abschnitt 11. Sie kann anlegen, lesen, ändern und löschen, sie hat eine Datenbank und einen Fehler-Handler. Jetzt bekommt sie das, was sie braucht, um öffentlich erreichbar zu sein.

Die Liste zum Abhaken

Acht Punkte, und jeder hat einen Grund:

PunktWogegenKam vor in
Eingaben prüfenerfundene Rollen, Unsinn in Zahlenfeldern15.1
Parameter statt VerkettungSQL-Injection15.2
Ausgabe maskierenXSS15.3
Secrets aus der UmgebungZugangsdaten im Repository15.4
Anfragen begrenzenDurchprobieren, teure Routen als Waffe15.5
Körpergröße begrenzenein Aufruf, der den Speicher fülltneu
Fehlermeldungen ohne Internadie halbe Architektur in einer 500neu
Sicherheitskopfzeilendie zweite Verteidigungslinieneu

Die ersten fünf hast du gebaut. Die letzten drei kommen hier dazu.

Maskieren heißt nicht überall dasselbe

Der häufigste Fehler bei genau dieser Liste ist, „Ausgabe maskieren” als Schalter zu verstehen, den man einmal umlegt.

Dieselben Daten, zwei Ausgaben
// Ein Titel, den jemand angelegt hat. Gespeichert wird er unverändert.
const buch = { id: 11, titel: '<script>alert("hallo")</script>', jahr: 2026 };

function maskiere(text) {
  return String(text)
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

console.log("Als JSON, richtig:");
console.log(`  ${JSON.stringify(buch)}`);
console.log("");
console.log("Als JSON, falsch maskiert:");
console.log(`  ${JSON.stringify({ ...buch, titel: maskiere(buch.titel) })}`);
console.log("");
console.log("Als HTML, richtig:");
console.log(`  <li>${maskiere(buch.titel)}</li>`);
console.log("");
console.log("Als HTML, falsch:");
console.log(`  <li>${buch.titel}</li>`);
console.log("");
console.log("Der Wert ist derselbe. Was richtig ist, entscheidet die Ausgabe.");

Vier Ausgaben desselben Buches, und zwei davon sind falsch. In HTML muss der Titel maskiert werden, sonst wird aus ihm ein Element. In JSON darf er es nicht, denn dort ist < ein ganz gewöhnliches Zeichen, und JSON.stringify kümmert sich um das, was in JSON wirklich gefährlich ist, nämlich Anführungszeichen und Zeilenumbrüche. Wer eine JSON-Antwort HTML-maskiert, liefert einem Programm einen Text aus, den nie jemand eingegeben hat, und irgendwann steht &amp;lt; in einer Datenbank.

Das ist dieselbe Regel wie in 15.3, nur streng gelesen: beim Ausgeben maskieren, und zwar für genau diese Ausgabe. Deine API hat zwei, und deshalb hat sie zwei Antworten auf die Frage.

Genau hier hilft übrigens eine der Kopfzeilen von unten. X-Content-Type-Options: nosniff sagt dem Browser, dass er den Content-Type ernst nehmen soll, statt am Inhalt zu raten. Ohne sie könnte er eine JSON-Antwort mit <script> darin für HTML halten und ausführen. Mit ihr bleibt JSON JSON, und der unmaskierte Titel ist wirklich harmlos.

Vier Kopfzeilen, und was Express schon von selbst tut

Welche Kopfzeilen Express schon setzt
import express from "express";

// Express' eingebauter Fehler-Logger legte sonst einen Stacktrace mit
// absoluten Pfaden auf stderr. Derselbe Griff wie in 10.5.
process.env.NODE_ENV = "test";

const app = express();

app.get("/ohne", (req, res) => res.json({ ok: true }));

app.get("/mit", (req, res) => {
  res.set("X-Content-Type-Options", "nosniff");
  res.set("Content-Security-Policy", "default-src 'none'");
  res.set("Referrer-Policy", "no-referrer");
  res.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
  res.json({ ok: true });
});

app.get("/kaputt", () => {
  throw new Error("etwas ist schiefgegangen");
});

const INTERESSANT = [
  "x-powered-by",
  "x-content-type-options",
  "content-security-policy",
  "referrer-policy",
  "strict-transport-security",
];

app.listen(3000);

Die Antwort ist hier die Kopfzeile, nicht der Inhalt. Vergleich die drei im Terminal:

curl -i http://localhost:3000/ohne

curl -i http://localhost:3000/mit

Der erste Fall ist der wichtige: Bei einer normalen Antwort setzt Express keine dieser Kopfzeilen. Bei einem unbehandelten Fehler dagegen schon zwei, weil seine eingebaute Fehlerseite sich selbst absichert. Wer seine Kopfzeilen also an einer Fehlerantwort ausprobiert, misst Express und nicht sich selbst.

Die vier in je einem Satz:

  • Content-Security-Policy sagt, aus welchen Quellen der Browser überhaupt etwas laden und ausführen darf. Für eine API ist default-src 'none' richtig, denn sie soll gar nichts laden. Das ist die zweite Verteidigungslinie gegen XSS, nach dem Maskieren.
  • X-Content-Type-Options: nosniff verbietet dem Browser, den Inhaltstyp zu erraten.
  • Referrer-Policy: no-referrer verhindert, dass beim Weiterklicken deine Adresse samt Query-Parametern an fremde Server geht. Wer je eine Kennung in eine URL geschrieben hat, weiß, warum.
  • Strict-Transport-Security sagt dem Browser, dass er diese Domain künftig nur noch über HTTPS ansprechen soll. Sie wirkt erst ab dem zweiten Besuch und nur über HTTPS, und sie ist die einzige der vier, die man versehentlich zu lange setzen kann.

In echten Projekten nimmt man dafür Helmet, ein Paket, das diese und ein Dutzend weitere Kopfzeilen mit vernünftigen Vorgaben setzt. Der Kurs setzt sie trotzdem von Hand, weil es acht Zeilen sind und weil man einmal gesehen haben sollte, was das Paket eigentlich tut.

Eine fünfte gehört noch dazu, und zwar in die andere Richtung: X-Powered-By: Express verrät, womit du arbeitest, und ist mit app.disable("x-powered-by") weg. Das ist keine echte Verteidigung, sondern nur weniger Auskunft.

Was eine Fehlerantwort verraten darf

Was eine Fehlerantwort verrät
import express from "express";

// Wie in 10.5: sonst legt Express' Logger absolute Pfade auf stderr.
process.env.NODE_ENV = "test";

const MELDUNG = "Verbindung zu postgres://kunde:s3cr3t@10.0.0.5/produktiv fehlgeschlagen";

function baue(variante) {
  const app = express();
  app.get("/kaputt", () => {
    throw new Error(MELDUNG);
  });

  if (variante === "eigener Handler, geschwätzig") {
    app.use((fehler, req, res, next) => {
      res.status(500).json({ fehler: fehler.message });
    });
  }
  if (variante === "eigener Handler, dicht") {
    app.use((fehler, req, res, next) => {
      console.error(`[intern] ${fehler.message}`);
      res.status(500).json({ fehler: { code: "serverfehler", meldung: "Da ist etwas schiefgegangen." } });
    });
  }
  return app;
}

// Die drei Varianten nebeneinander statt nacheinander: jede haengt
// unter ihrem eigenen Pfad, und du vergleichst die Antworten selbst.
const app = express();
app.use("/ohne", baue("kein eigener Handler"));
app.use("/geschwaetzig", baue("eigener Handler, geschwätzig"));
app.use("/dicht", baue("eigener Handler, dicht"));

app.listen(3000);

Drei Fassungen derselben kaputten Route, jede unter ihrem eigenen Pfad. Rechts steht die erste, /ohne/kaputt. Vergleich sie mit /geschwaetzig/kaputt und /dicht/kaputt, und such in jeder Antwort nach zwei Dingen: dem Wort s3cr3t und dem Dateinamen.

Ohne eigenen Handler liefert Express eine Seite mit dem vollständigen Stacktrace: Dateinamen, Zeilennummern, die Verzeichnisstruktur deines Servers und nebenbei das Datenbankpasswort, das zufällig in der Fehlermeldung stand. Fast zwei Kilobyte Auskunft für jemanden, der nur eine Adresse aufgerufen hat.

Die zweite Fassung sieht auf den ersten Blick aufgeräumt aus und ist der häufigste Fehler in echtem Code: fehler.message in die Antwort. Der Stacktrace ist weg, das Passwort steht immer noch drin. Eine Fehlermeldung ist für dich geschrieben, nicht für den Aufrufer.

Die dritte ist die richtige, und sie ist die kürzeste. Nach außen ein Statuscode, ein maschinenlesbarer Code und ein Satz. Nach innen, auf stderr, die ganze Wahrheit. Genau diese Trennung hast du in 11.6 schon einmal gebaut, hier ist sie die Regel für alles.

Dazu kommt die Körpergröße. express.json() nimmt standardmäßig 100 kB entgegen, und für eine API, die Titel und Jahreszahlen verwaltet, ist das drei Größenordnungen zu viel. express.json({ limit: "1kb" }) antwortet stattdessen mit 413, und zwar bevor der Körper überhaupt vollständig gelesen ist.

Der Satz, auf den es hinausläuft

Keiner der acht Punkte ist für sich genommen schwierig. Was sie schwierig macht, ist, dass sie nicht zusammen an einer Stelle stehen: Die Prüfung gehört in die Route, das Maskieren in die Ausgabe, die Kopfzeilen ganz nach oben, die Fehlerbehandlung ganz nach unten, und das Secret gehört gar nicht in den Code.

Deshalb funktioniert „Sicherheit machen wir dann am Ende” nicht. Es gibt keine Stelle, an der man sie am Ende einbauen könnte. Sicherheit ist eine Entscheidung an jeder einzelnen Stelle, an der etwas von außen hereinkommt, und die Liste oben ist nur die Erinnerung daran, wo diese Stellen liegen.

Zum Mitnehmen

Sicherheit ist keine Schicht, die man am Ende darüberlegt, sondern eine Entscheidung an jeder einzelnen Stelle, an der etwas von außen hereinkommt.

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.