Dein Einladungslink ist ein Passwort, das du in Slack gepostet hast

security

Am Montag fängt ein neuer Entwickler an. Du postest den Beitrittslink in den Onboarding-Channel, er klickt darauf und ist drin, ohne E-Mail-Pingpong und ohne auf eine Bestätigungsmail zu warten, die im Spam gelandet ist.

Vier Monate später gibt es den Channel noch, und den Link auch. Er funktioniert immer noch. Er funktioniert für den Freelancer, der in Woche sechs in den Channel kam, für jeden, dem dieser ihn weitergeleitet hat, und für den, der irgendwann das exportierte Archiv öffnet.

Ein Einladungslink ist ein Bearer-Token. Er weiß nicht, wer du bist, nur dass du ihn hast. Darin liegt sein ganzer Reiz und sein ganzes Problem. Hier steht, was ein Link mitbringen muss, bevor du ihn irgendwo gefahrlos postest, was ich in Kookee gebaut habe und welche Teile schwieriger waren, als sie aussahen.

Eine Einladung per E-Mail hat einen Empfänger. Das System weiß schon, wer hinein darf, nämlich die Adresse, an die es die Mail geschickt hat, und das Token in dieser Mail nützt sonst niemandem. Das ist präzise und langsam: Jemand muss Adressen eintippen, die Mail muss ankommen, der Empfänger muss sie finden.

Beim Link ist es umgekehrt: Er wirkt sofort und hat keinen Empfänger. Wer ihn öffnet, den lässt er herein.

Die meisten Teams wollen beides, aus demselben Grund, aus dem sie eine Gästeliste und eine Tür wollen. Der Fehler ist, den Link so zu bauen, als wäre er die Einladung, nur ohne das Tippen.

Wer darf ihn benutzen? Ohne Antwort lautet die ehrliche Beschreibung der Funktion: „Jeder im Internet, der diese Zeichenkette sieht, tritt deinem Projekt bei.“ Ein Link, den man auf die Domains beschränken kann, die die Adressen deines Teams tatsächlich verwenden, ist etwas anderes als einer, bei dem das nicht geht.

Wie lange bleibt er offen? Jeder Link, der nie abläuft, wird irgendwann zu einem Link, an dessen Erstellung sich niemand erinnert. Die Voreinstellung zählt mehr als die Option: Ist „Nie“ voreingestellt, steht bei den meisten Links „Nie“.

Wer kann ihn zurücknehmen? Widerrufen muss ein Klick sein, und die Liste der aktiven Links muss dort stehen, wo ein Mensch auch hinschaut. Einen Zugang, den du nicht auflisten kannst, kannst du auch nicht widerrufen.

Was gewährt er? Die Antwort sollte die kleinste brauchbare Rolle sein, und zwar ausnahmslos. Ein Link, der Admin-Rechte vergeben kann, vergibt auch das Recht, weitere Links zu erstellen.

Eine Domain-Regel ist ohne verifizierte Adresse wertlos

Der naheliegende Weg, einen Link einzuschränken, ist eine E-Mail-Domain. Weniger naheliegend: Die Einschränkung bedeutet nur dann etwas, wenn die Adresse bestätigt ist.

TypeScript
if (link.allowedDomains?.length) {
  const allowed = link.allowedDomains.includes(domainOf(user.email));
  if (!user.emailVerified || !allowed) throw Forbidden('INVITE_LINK_DOMAIN_MISMATCH');
}

Lass die Prüfung auf emailVerified weg, und die Funktion kehrt sich um: Wer den Link öffnet, registriert sich als someone@yourcompany.com, öffnet die Bestätigungsmail nie und ist trotzdem drin. Die Domain-Regel wäre dann nur noch ein Hinweis darauf, wen du dir im Team gewünscht hättest.

Darunter liegen zwei kleinere Entscheidungen.

Einträge in der Allowlist werden maschinell verglichen, nicht von Menschen gelesen. Ein kyrillisches „а“ ist ein anderes Zeichen als ein lateinisches „a“, sieht aber genauso aus. Ein Homoglyph in der Allowlist ist also eine offene Tür, die geschlossen aussieht. Einträge bestehen aus ASCII-Buchstaben, Ziffern und Bindestrichen, und eine internationalisierte Domain wird in ihrer Punycode-Form eingetragen:

TypeScript
// ASCII-Buchstaben, Ziffern und Bindestriche, mit TLD. IDN-Domains werden als xn--… eingetragen
const DOMAIN = /^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/;

Die gespeicherte Domain muss die Domain sein, mit der verglichen wird. Kookee normalisiert Adressen schon bei der Eingabe, und googlemail.com ist dasselbe Postfach wie gmail.com. Eine Regel mit googlemail.com würde deshalb nie auf jemanden passen: Sie sieht richtig aus, lässt sich ohne Fehlermeldung speichern und weist stillschweigend alle ab. Die Domain beim Anlegen der Regel umzuschreiben kostet drei Zeilen und erspart einen Support-Thread, den man von außen nie hätte diagnostizieren können.

Ein Admin erstellt im März einen Link. Im Juni wechselt er in ein anderes Team und wird zum Mitglied herabgestuft, oder er geht und wird entfernt, oder er wird gesperrt. Der Link steht immer noch im Channel.

Es gibt zwei vertretbare Ansätze. Entweder du prüfst den Ersteller beim Beitritt: Bei jedem Beitritt liest du seine aktuelle Rolle und lehnst ab, wenn er sie nicht mehr hat. Oder du widerrufst den Link in dem Moment, in dem das Ereignis eintritt. Der erste Ansatz klingt korrekter und ist schlechter: Er fügt jedem Beitritt eine Abfrage hinzu und muss unangenehme Fragen beantworten, etwa was der Link eines Inhabers bedeutet, nachdem das Projekt in andere Hände übergegangen ist.

Kookee widerruft, sobald das Ereignis eintritt. Wird ein Mitglied entfernt, als Inhaber oder Admin herabgestuft oder gesperrt, werden alle Links widerrufen, die es erstellt hat, in derselben Datenbanktransaktion wie die Änderung der Mitgliedschaft. Die Regel dahinter lässt sich in einem Satz sagen: Ein Link ist der verlängerte Arm der Person, die ihn erstellt hat, und er gilt nur so lange wie ihre Befugnis.

Plätze sind die andere Hälfte der Frage

Ein Link, den jeder weiterleiten kann, ist bei einem Plan mit fester Zahl an Plätzen auch ein Weg, fremdes Geld auszugeben. Deshalb läuft die Prüfung der Plätze beim Beitritt, nicht nur bei der Einladung:

TypeScript
const seatsInUse = members + pendingInvitations;
if (seatsInUse + 1 > plan.members.maxPerProject) throw Conflict('MEMBER_LIMIT_REACHED');

Offene Einladungen mitzuzählen vergisst man leicht. Ohne sie nehmen drei Plätze zehn Einladungen auf, alle bekommen eine Willkommensmail, und die letzten sieben sehen beim Annehmen einen Fehler: der denkbar schlechteste Moment, jemandem zu sagen, dass das Team voll ist.

Sie doppelt zu zählen ist der umgekehrte Bug. Wurde jemand schon per E-Mail eingeladen und tritt dann stattdessen über einen Link bei, ist sein Platz bereits reserviert. Ihm einen zweiten anzurechnen, würde einen Beitritt ablehnen, der offensichtlich in Ordnung ist. Seine eigene offene Einladung zählt deshalb nicht mit und wird durch den Beitritt geschlossen.

Was ich gebaut habe

Inhaber und Admins finden auf der Team-Seite des Projekts den Bereich Einladungslinks. Dort steht jeder aktive Link mit dem, was er erlaubt, und seinem Ablaufdatum, daneben je ein Button zum Kopieren und zum Widerrufen.

Team-Seite mit einer Platzanzeige bei 2 von 3 belegten Plätzen, der Mitgliederliste und zwei Einladungslinks: einer für jede E-Mail-Adresse offen, einer auf lumen.example beschränkt

Beim Erstellen triffst du zwei Entscheidungen: welche E-Mail-Domains den Link nutzen dürfen und wie lange er offen bleibt. Lässt du die Domains leer, kann jede Adresse beitreten. Trägst du welche ein, lässt der Link nur verifizierte Adressen dieser Domains herein. Als Ablauf gibt es 7 Tage, 30 Tage oder „Nie“, und wenn du nichts auswählst, sind es 30 Tage.

Dialog „Einladungslink erstellen“: das Feld „Erlaubte E-Mail-Domains“ mit lumen.example und die Auswahl „Ablauf des Links“ auf 30 Tage

Wer den Link öffnet, sieht das Projekt, dem er beitritt, und die Einschränkung, falls es eine gibt. Er muss mit einem echten Konto angemeldet sein, denn eine Gastsitzung bekommt keinen Platz, und er tritt immer als Mitglied bei, nie als Admin.

Die Beitrittsseite eines auf eine Domain beschränkten Links: Sie sagt dem Besucher, dass der Link eine verifizierte E-Mail-Adresse bei lumen.example verlangt

Ein Limit, das man erst beim Erreichen bemerkt, ist ein Bug-Report

Plätze sind eines von mehreren Limits, die ein Plan für ein Projekt setzt, und für alle gilt dieselbe Überlegung: Eine Zahl, die man erst kennt, wenn man an sie stößt, kommt bei dir als verärgerte Nachricht an statt als Upgrade.

Deshalb zeigt jedes Projekt jetzt, was sein Plan erlaubt, neben dem, was es nutzt: Plätze, Einträge, API-Schlüssel, Kategorien, Tags, Eintragstypen und Konfigurationen. Und jede Liste warnt, bevor sie voll ist, nicht erst danach.

Bereich „Plan und Nutzung“ im Free-Plan: Team-Plätze 2 von 3, API-Schlüssel 0 von 10, Einträge 5 von 2.000 und Anzeigen für Kategorien, Tags, Eintragstypen und Konfigurationen

  • Behandle den Link als Bearer-Token, denn genau das ist er. Entwirf ihn wie ein geteiltes Passwort, nicht wie eine E-Mail.

  • Beschränke ihn auf verifizierte Domains und lehne nicht verifizierte Adressen konsequent ab. Mit einer nicht verifizierten Adresse ist die Domain-Regel nur Dekoration.

  • Lass in der Allowlist nur ASCII zu und normalisiere die Einträge genauso wie die Adressen, mit denen du sie vergleichst.

  • Gib ihm standardmäßig ein Ablaufdatum und mach „Nie“ zur bewussten Wahl, nicht zur bequemen.

  • Vergib die niedrigste brauchbare Rolle. Ein Link darf nie weitere Links erzeugen können.

  • Widerrufe einen Link, sobald die Person, die ihn erstellt hat, die Befugnis verliert, mit der sie ihn erstellt hat.

  • Rechne offene Einladungen auf dein Platzlimit an und zähle jede Person nur einmal.

  • Zeige jeden aktiven Link dort, wo ein Mensch hinschaut, und mach das Widerrufen zu einem einzigen Klick.


Projekte in Kookee haben jetzt teilbare Einladungslinks, und jedes Projekt zeigt die Limits seines Plans. Wie du einen Link einrichtest, steht im Artikel Das Team zu einem Projekt einladen, was jeder Plan erlaubt, im Artikel Rate Limits, Pläne und Fehlerbehandlung.