mitmario.dev

Zu viele Anfragen

Node.js Sandbox 3 Min Lesezeit 3 BeispieleLektion 5 von 8

Eine Anmelderoute, die beliebig oft antwortet, ist eine Einladung. Nicht weil ein Angreifer dein Passwort errät, sondern weil er es tausendmal pro Minute probieren darf, und die meisten Passwörter halten das nicht aus.

Wogegen eine Begrenzung hilft

Drei Fälle, und sie sehen von außen ähnlich aus:

  • Passwörter durchprobieren. Der offensichtliche Fall. Eine Liste der zehntausend häufigsten Passwörter ist in einer Minute durch, wenn niemand bremst.
  • Adressen abgrasen. Der Angreifer will gar nicht hinein, er will wissen, wer Kunde bei dir ist. Deshalb ist 14.3 wichtig, und deshalb ist eine Bremse die zweite Hälfte derselben Antwort.
  • Eine teure Route als Waffe. Wenn ein Aufruf eine Sekunde Rechenzeit kostet, braucht es keinen Botnetz, um deinen Server umzulegen. Es reicht eine Schleife.

Der einfachste Zähler, der trägt

Fünfzehn Zeilen, die reichen
// Die Uhr kommt als Argument, damit dieses Beispiel jedes Mal dasselbe
// zeigt. In echt steht dort Date.now().
const MAX_VERSUCHE = 3;
const FENSTER_MS = 60_000;
const zaehler = new Map();

function pruefe(schluessel, jetzt) {
  const eintrag = zaehler.get(schluessel);

  if (!eintrag || eintrag.bis <= jetzt) {
    zaehler.set(schluessel, { versuche: 1, bis: jetzt + FENSTER_MS });
    return { erlaubt: true, uebrig: MAX_VERSUCHE - 1 };
  }
  if (eintrag.versuche >= MAX_VERSUCHE) {
    return { erlaubt: false, wartet: Math.ceil((eintrag.bis - jetzt) / 1000) };
  }
  eintrag.versuche += 1;
  return { erlaubt: true, uebrig: MAX_VERSUCHE - eintrag.versuche };
}

for (const sekunde of [0, 5, 10, 15, 20, 61, 65]) {
  const { erlaubt, uebrig, wartet } = pruefe("127.0.0.1|test@beispiel.de", sekunde * 1000);
  const antwort = erlaubt ? `200, noch ${uebrig} frei` : `429, Retry-After: ${wartet}`;
  console.log(`Sekunde ${String(sekunde).padStart(2)}   ${antwort}`);
}

Mehr ist es nicht: eine Map von einem Schlüssel auf { versuche, bis }. Drei Fälle, in dieser Reihenfolge. Gibt es keinen Eintrag oder ist das Zeitfenster abgelaufen, fängt ein neues an. Ist die Grenze erreicht, wird abgewiesen. Sonst wird hochgezählt und durchgelassen.

Das Zeitfenster im Beispiel ist ein festes Intervall: Ab Sekunde 61 ist das alte abgelaufen und ein neues beginnt. Das ist die einfachste Bauart und für eine Anmelderoute völlig ausreichend. Sie hat eine bekannte Schwäche, die man kennen sollte: An der Grenze zweier Fenster passen doppelt so viele Anfragen durch, weil das eine gerade endet und das nächste sofort voll ausgeschöpft wird. Wer das nicht will, nimmt ein gleitendes Fenster, und das ist deutlich mehr Code für einen Unterschied, der beim Passwortraten keine Rolle spielt.

Was zurückkommt, ist 429 Too Many Requests, und dazu gehört eine Retry-After-Kopfzeile mit der Zahl der Sekunden. Sie ist kein Schmuck: Ein ehrlicher Aufrufer, etwa dein eigenes Frontend oder ein Programm, das deine API benutzt, kann daraus ablesen, wann es wiederkommen darf, statt weiter gegen die Wand zu laufen.

Die eigentliche Frage: wogegen wird gezählt

Der Zähler ist der einfache Teil. Der Schlüssel ist die Entscheidung.

Wogegen gezählt wird
const MAX_VERSUCHE = 5;

// Drei Schlüssel, dieselbe Bremse. Jeder Durchlauf bekommt einen
// frischen Zähler, sonst sähen sich die Versuche gegenseitig.
const SCHLUESSEL = {
  "nur IP": (a) => a.ip,
  "nur Adresse": (a) => a.email,
  "IP und Adresse": (a) => `${a.ip}|${a.email}`,
};

function durchgelassen(schluesselVon, anfragen) {
  const zaehler = new Map();
  return anfragen.filter((a) => {
    const key = schluesselVon(a);
    const versuche = (zaehler.get(key) ?? 0) + 1;
    zaehler.set(key, versuche);
    return versuche <= MAX_VERSUCHE;
  }).length;
}

const buero = Array.from({ length: 6 }, (_, i) => ({ ip: "203.0.113.9", email: `kollege${i + 1}@firma.de` }));
const einAngreifer = Array.from({ length: 6 }, () => ({ ip: "198.51.100.7", email: "chefin@firma.de" }));
const verteilt = Array.from({ length: 6 }, (_, i) => ({ ip: `198.51.100.${i + 1}`, email: "chefin@firma.de" }));

console.log("Je sechs Anfragen, Grenze fünf. Wie viele kommen durch?");
console.log("");
console.log("                 Büro, 6 Kollegen   Angriff, 1 IP   Angriff, 6 IPs");
for (const [name, schluesselVon] of Object.entries(SCHLUESSEL)) {
  const a = durchgelassen(schluesselVon, buero);
  const b = durchgelassen(schluesselVon, einAngreifer);
  const c = durchgelassen(schluesselVon, verteilt);
  console.log(`${name.padEnd(16)} ${String(a).padStart(9)}          ${String(b).padStart(6)}          ${String(c).padStart(7)}`);
}
console.log("");
console.log("Links ist sechs gut, rechts ist fünf gut. Eine Spalte bleibt rot.");

Drei Möglichkeiten, drei Fälle, und keine Zeile gewinnt überall.

Nur die IP ist die naheliegende Wahl und die schlechteste. In der linken Spalte wird ein ehrlicher Kollege ausgesperrt, weil sich ein ganzes Büro, ein Studentenwohnheim oder ein Mobilfunknetz eine Adresse teilt. In der Spalte ganz rechts, dem Angriff aus sechs Netzen, nützt sie gar nichts, denn wer angreift, hat selten nur eine IP.

Nur die Adresse sieht in dieser Tabelle am besten aus: Das Büro kommt durch, und beide Angriffe laufen in die Bremse, auch der verteilte. Der Preis steht nicht in der Tabelle: Wer die Adresse seines Opfers kennt, kann sie damit gezielt aussperren, indem er selbst sechsmal falsch anklopft. Aus einer Bremse wird eine Waffe.

Die Kombination hält das Büro frei und stoppt den Angriff aus einer Richtung, und sie kann von niemandem missbraucht werden, um ein fremdes Konto zu sperren. Was sie nicht kann, steht in der letzten Spalte. Deshalb zählen echte Anmelderouten auf zwei Zählern gleichzeitig: einem engen auf der Kombination und einem weiten auf der Adresse allein, der erst bei sehr viel höheren Zahlen greift. Die Challenge baut den ersten, und das ist der, der im Alltag arbeitet.

Was ein Zähler im Arbeitsspeicher nicht kann

Was der Zähler im Arbeitsspeicher nicht kann
const MAX_VERSUCHE = 5;

// Eine Instanz der Anwendung. Ihr Zähler liegt in ihrem eigenen
// Arbeitsspeicher, und den sieht keine zweite Instanz.
function neueInstanz(name) {
  const zaehler = new Map();
  return {
    name,
    pruefe(schluessel) {
      const versuche = (zaehler.get(schluessel) ?? 0) + 1;
      zaehler.set(schluessel, versuche);
      return versuche <= MAX_VERSUCHE;
    },
  };
}

function probiere(instanzen) {
  let durch = 0;
  for (let versuch = 1; versuch <= 12; versuch += 1) {
    // So verteilt ein Lastverteiler reihum.
    const instanz = instanzen[(versuch - 1) % instanzen.length];
    if (instanz.pruefe("203.0.113.9|chefin@firma.de")) durch += 1;
  }
  return durch;
}

console.log(`Eine Instanz:   ${probiere([neueInstanz("A")])} von 12 Versuchen kamen durch`);
console.log(`Zwei Instanzen: ${probiere([neueInstanz("A"), neueInstanz("B")])} von 12 Versuchen kamen durch`);
console.log(`Vier Instanzen: ${probiere([neueInstanz("A"), neueInstanz("B"), neueInstanz("C"), neueInstanz("D")])} von 12 Versuchen kamen durch`);
console.log("");
console.log("Die Grenze steht auf fünf. Sie gilt pro Instanz, nicht pro Anwendung.");

Eine Map lebt im Arbeitsspeicher genau eines Prozesses. Sobald deine Anwendung zweimal läuft, gibt es zwei Maps, und ein Lastverteiler schickt die Anfragen abwechselnd hin und her. Aus fünf pro Minute werden zehn, bei vier Instanzen zwanzig, und die Grenze, die in deinem Code steht, ist nicht mehr die, die gilt. Dazu kommt: Bei jedem Neustart ist der Zähler leer.

Für viele Anwendungen ist das trotzdem in Ordnung, und es ist besser als keine Bremse. Sobald es darauf ankommt, braucht es einen gemeinsamen Speicher, also Redis oder die Datenbank.

Genau diese Unterscheidung gibt es auch im Repository dieser Seite: Für die meisten Endpunkte liegt der Zähler im Arbeitsspeicher, und für die wenigen, bei denen es wirklich zählt, liegt er in der Datenbank. Nicht überall dasselbe, sondern jeweils das, was die Stelle braucht.

Zum Mitnehmen

Fünfzehn Zeilen und eine Map. Die Frage ist nicht, wie du zählst, sondern wogegen du zählst, und die falsche Antwort sperrt ein ganzes Büro aus.

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.