02Sicherheit5 min

Warum ich Gastzugänge mit Session-Token mache und nicht mit Codes

Ein sechsstelliger Code sieht aus wie der einfache Weg, jemanden ohne Konto reinzulassen. Das ist er auch, bis du ihn widerrufen musst und merkst, dass du nicht weißt, wen du gerade rauswirfst.

Von Salva Castells

Titelbild: Jessica, CC BY-SA 2.0.
In diesem Beitrag

Jemand will einem Kunden ohne Konto ein Projekt zeigen. Die offensichtliche Lösung ist ein kurzer Code: sechs Ziffern, läuft nach fünf Minuten ab, der Gast tippt ihn ein und ist drin. Ich habe es in einem Tool für geteilte Notizen und Dateien genau so umgesetzt, und es lief vom ersten Tag an. Das Problem war nicht, dass es nicht funktionierte. Das Problem war, dass ich es drei Monate später zweimal neu bauen musste.

Drei Iterationen, und die dritte hätte die erste sein sollen.

Was ein kurzer Code leistet und was nicht

Die erste Tabelle war wörtlich das hier:

CREATE TABLE guest_access_codes (
  id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
  user_id text NOT NULL,
  code char(6) NOT NULL,
  permission permission NOT NULL,
  session_duration_minutes integer NOT NULL,
  code_expires_at timestamptz NOT NULL,
  is_used boolean DEFAULT false NOT NULL,
  used_at timestamptz,
  session_expires_at timestamptz
);

Zwei verschiedene Ablaufzeiten, und das war von Anfang an richtig: code_expires_at ist, wie lange der Code lebt, wenn ihn niemand benutzt (fünf Minuten), und session_duration_minutes ist, wie lange der Besuch dauert, sobald er drin ist. Das sind verschiedene Dinge, und sie zu vermischen ist ein klassischer Fehler. Ein Code, der acht Stunden lang in WhatsApp lebt, ist ein Loch; eine Session, die nach fünf Minuten abbricht, ist unbenutzbar.

Damit hast du schon Berechtigungen (read oder read_write), eine Zwischentabelle, um zu entscheiden, auf welche Projekte der Code Zugriff gibt, und Ablaufzeiten. Sieht vollständig aus. Ist es nicht: Der Code identifiziert niemanden. Er identifiziert die Einladung, nicht den Gast.

Erster Versuch: die Zeilen-ID durchreichen

Die erste Korrektur war, nicht mehr den sechsstelligen Code bei jeder Anfrage mitzuschicken, sondern die id der Zeile. Der Client speicherte eine codeId, das Backend löste sie auf, und die Anfragen liefen mit Authorization: Bearer <codeId>.

Das behebt etwas Echtes: Der sechsstellige Code reist nicht mehr mit. Er steht nicht mehr in einem Zugriffslog oder in einer URL. Aber es führt etwas Schlimmeres ein. Die codeId ist ein autoinkrementeller Integer. Sie ist erratbar. Sie ist enumerierbar. Jeder mit einer gültigen ID kann die daneben ausprobieren, und wenn diese Session noch lebt, kommt er rein. Ich habe ein kurzes Geheimnis gegen einen Identifikator getauscht, der überhaupt kein Geheimnis ist.

Und das ursprüngliche Problem bleibt ungelöst, nämlich dieses: Wenn zwei Personen dieselbe Einladung benutzen, teilen sie sich dieselbe codeId. Du kannst die eine nicht rauswerfen, ohne die andere rauszuwerfen. Du kannst nicht wissen, welche von beiden die Datei gelöscht hat.

Was der Session-Token löst

Die dritte Version trennt die zwei Dinge, die drei Monate lang zusammenklebten. Der sechsstellige Code ist ein Einlöseschein: Er wird einmal benutzt, im Verbinden-Endpoint, und nie wieder. Was dabei herauskommt, ist ein Session-Token:

const suffix = randomBytes(4).toString("hex");
const sessionToken = `${code}-${suffix}`;

await db
  .update(guestAccessCodes)
  .set({ isUsed: true, usedAt: now, sessionExpiresAt, sessionToken })
  .where(eq(guestAccessCodes.id, record.id));

Die Antwort gibt keine codeId mehr zurück, sondern einen sessionToken. Ab da schleppt ihn das ganze Frontend mit: Wo guestCodeId stand, steht jetzt guestSessionToken, und aus dem !!guestCodeId, das entschied, ob jemand Gast war, wurde !!guestSessionToken.

Was sich wirklich ändert:

  • Widerrufen. Den sessionToken in einer Zeile auf null zu setzen wirft genau diese Session raus und nur diese. Beim Code hattest du kein Ziel.
  • Auditieren. Der Token steht in der Zeile, also lässt sich jede Aktion einem konkreten Einlösen und einer konkreten Uhrzeit zuordnen.
  • Nicht erratbar. Es ist kein fortlaufender Integer mehr. Vier zufällige Bytes in Hex enumeriert man nicht.
  • Nur ein Ort, an dem etwas abläuft. Das sessionExpiresAt wird im selben Update geschrieben, das den Token ausgibt. Es gibt keine zwei Uhren, die auseinanderlaufen können.

Was das Audit gefunden hat und ich nicht

Monate später habe ich den Code auditiert. Es kam alles Mögliche heraus — eine PDF-Export-Route ganz ohne Auth-Guard ist das Schlimmste der Liste — aber das, was mich am meisten nachdenken ließ, habe ich selbst ins TODO geschrieben: Auch wenn der Sechser-Code verbraucht wird, ist das, was zurückkommt, ein Token aus sechs plus vier, und der authentifiziert.

Da liegt der Fehler. Der kurze Code ist durch seine Lebensdauer von fünf Minuten geschützt und dadurch, dass er nur einmal benutzbar ist. Der Session-Token hat nichts von beidem: Er lebt Stunden und wird bei jeder Anfrage benutzt. Von Code auf Token umzustellen nimmt einen Vektor weg und öffnet einen anderen, und das merkst du nur, wenn du später mit kühlem Kopf draufschaust. Vier Bytes reichen gegen beiläufiges Enumerieren, nicht gegen jemanden mit acht Stunden Zeit und einer Schleife.

Das andere, was herauskam und aus dem Lehrbuch stammt: Der Verbinden-Endpoint umschloss nichts mit einer Transaktion. Zwischen dem Lesen, dass der Code unbenutzt ist, und dem Markieren als benutzt liegt ein Fenster. Zwei gleichzeitige Anfragen mit demselben Code kamen beide durch.

IP-Sperren sind fragiler, als sie aussehen

Der Zähler für Fehlversuche muss wissen, von welcher IP die Anfrage kommt, und genau da macht fast jeder denselben Fehler. In einem anderen Projekt hatte ich permanente IP-Sperren und nahm den ersten Wert aus x-forwarded-for. Dieser Header ist eine Liste client, proxy1, proxy2: Jeder Proxy hängt rechts an, aber niemand räumt auf, was schon darin stand. Den ersten zu nehmen heißt, etwas zu nehmen, das sich der Client ausdenken kann.

Ich habe es umgestellt auf Zählen vom Ende her, je nachdem, wie viele vertrauenswürdige Proxys davor stehen:

const TRUSTED_PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS || 1);
const list = String(xff).split(",").map(s => s.trim()).filter(Boolean);
const idx = list.length - TRUSTED_PROXY_HOPS;
return list[idx >= 0 ? idx : 0];

Mit einem Proxy davor ist der letzte Wert der, den der Proxy schreibt und den der Client nicht anfassen kann. Wenn du den ersten nimmst, umgeht jeder seine Sperre, indem er Müll schickt, oder schlimmer, er sperrt jemand anderen.

Der sechsstellige Code war nicht der schwierige Teil. Der schwierige Teil war zu akzeptieren, dass eine Einladung und eine Session zwei verschiedene Objekte sind, und dass du sie vermischt weder widerrufen noch auditieren kannst und einen fortlaufenden Identifikator als Credential im Einsatz hast.