Ein Bindestrich, zwei Mandanten, ein Signaturschlüssel
Zwei unserer Mandanten waren derselbe Mandant. Sie hatten unterschiedliche Namen, unterschiedliche Registrierungen und unterschiedliche Abrechnungszeilen. Sie teilten sich außerdem eine Datenbank und einen Schlüssel zur Token-Signatur, und keiner von uns dreien, weder die beiden Mandanten noch wir, hatte eine Ahnung davon. Dies ist die Geschichte der einzeiligen Funktion, die sie zusammenführte, warum jede Schicht des Systems während des gesamten Vorgangs vollkommen korrekt aussah, und warum ein Audit vor dem Launch die günstigste Versicherung ist, die Sie je abschließen werden.
Multi-Tenant-Auth hat genau eine Aufgabe, die nicht schiefgehen darf: Mandanten voneinander trennen. Die Benutzer von Acme, die Sitzungen von Acme und vor allem der Signaturschlüssel von Acme dürfen für niemanden sonst erreichbar sein. Der Signaturschlüssel ist das Kronjuwel. Wer mit dem Schlüssel von Acme signieren kann, kann ein Token ausstellen, das der eigene Auth-Server von Acme als echt akzeptiert, für jeden beliebigen Benutzer, mit jeder beliebigen Rolle, ganz ohne Passwort. Deshalb wird jede mandantenspezifische Ressource, die wir erzeugen, jede Storage-Tabelle und jeder Schlüssel, mit dem Slug des Mandanten in einen Namensraum eingeordnet. Wird diese Namensraum-Zuordnung richtig gemacht, sind Mandanten Inseln. Wird sie auf subtile Weise falsch gemacht, werden sie klammheimlich zum selben Ort.
Der einzeilige Bug
Hier ist die Funktion, die aus einem Mandanten-Slug das Präfix macht, mit dem wir seinen Storage und seine Schlüssel benennen. Lesen Sie den Doc-Kommentar. Er dokumentiert den Bug, als wäre er ein Feature.
/// Derive the table name prefix from a tenant slug by stripping hyphens.
/// E.g. "acme-corp" → "acmecorp".
public static string GetTablePrefix(string tenantSlug)
{
return tenantSlug.Replace("-", "");
}
Replace("-", ""). Es entfernt Bindestriche. Die Absicht war Aufräumarbeit: Slugs fließen in Azure-Table-Namen und Vault-Schlüsselnamen ein, die ihre eigenen Zeichenregeln haben, also haben wir sie bereinigt. Das Problem ist, dass das Entfernen von Zeichen eine verlustbehaftete Transformation ist, und eine verlustbehaftete Transformation auf einem Bezeichner ist nicht injektiv. acme-corp und acmecorp ergeben beide acmecorp. Ebenso ac-me-corp und acme--corp. Unterschiedliche Slugs, ein Namensraum.
Dieser Namensraum ist alles, was nachgelagert folgt. Die Benutzertabelle heißt {prefix}-Users. Und der mandantenspezifische Signaturschlüssel lautet, wortwörtlich, so:
private string GetKeyName() => $"signing-{ShardRouter.GetTablePrefix(_tenantContext.Slug)}";
Also teilen sich acme-corp und acmecorp nicht nur eine Benutzerliste. Sie signieren ihre Token mit demselben signing-acmecorp-Schlüssel in Vault. Ein für den einen ausgestelltes Token ist, Byte für Byte, ein gültig signiertes Token für den anderen. Wenn Sie einen Slug registrieren können, der auf dasselbe Präfix wie ein bestehender Mandant kollabiert, können Sie sich selbst Token ausstellen, denen dessen Auth-Server vollständig vertraut. Mandantenübergreifende Kontoübernahme, und der Exploit lautet: "Registriere dich mit einem Bindestrich."
Warum nichts es abgefangen hat
Das Verstörende daran ist, wie gewöhnlich jede Schicht aussah. Die Registrierung validierte den Slug und sah einen frischen, unbenutzten String. Das Provisioning erstellte einen Mandanten-Datensatz mit dem vollständigen Slug acme-corp als Schlüssel, der in der Control Plane tatsächlich von acmecorp verschieden ist. Die Token-Validierung leitete den Schlüssel aus dem Slug ab und verifizierte zufrieden. Jede Komponente erledigte ihre Aufgabe korrekt auf ihrer eigenen Eingabe.
Nichts im System verglich jemals die Präfixe zweier Slugs, weil keine einzelne Komponente die Invariante besaß: "Ein Slug bildet auf genau einen Namensraum ab." Die Kollision lebte in der Lücke zwischen einem Slug, auf dem die Control Plane ihren Schlüssel bildet, und einem Präfix, auf dem Storage und Vault ihren Schlüssel bilden. Niemand stand in dieser Lücke. Das ist die Signatur der gefährlichsten Klasse von Bugs: keine Prüfung, die jemand vergessen hat, sondern eine Annahme, von der niemand wusste, dass er sie traf.
Die zweite Kollision, gratis dazu
Dieselbe verlustbehaftete Transformation hatte ein zweites Opfer. Unsere internen System-Mandanten werden per Suffix benannt: {slug}-admin beherbergt das Portal-Team eines Mandanten, {slug}-sandbox beherbergt seine Testumgebung. Schickt man die durch denselben Bindestrich-Entferner, wird aus acme-admin ein acmeadmin. Das bedeutet, dass ein Kunde, der den Slug acmeadmin registriert hat, auf das Präfix von Acmes Admin-Mandanten kollabieren würde, ausgerechnet die eine Stelle, die privilegierter als der Kunde sein soll, nicht mit einem geteilt.
Eine einzige Entfern-Funktion, zwei verschiedene Isolationsgrenzen in Gefahr: Mandant zu Mandant, und Mandant zu seiner eigenen Control Plane. Wenn eine einzelne Zeile zwei voneinander unabhängige Grenzen bedroht, ist das das verräterische Zeichen eines Ursachen-Bugs und nicht eines oberflächlichen. Die Korrektur muss bei der Transformation ansetzen, nicht bei einem der beiden Symptome.
Die Korrektur: injektiv von Bauart her
Der erste Instinkt ist, die Präfix-Funktion klüger zu machen. Die Bindestriche escapen, den Slug hashen, ihn base32-kodieren. Jede dieser Optionen ist immer noch eine Transformation, deren Injektivität Sie für immer beweisen müssen, gegen jede künftige Änderung, durch jemanden, der vielleicht nicht weiß, warum es darauf ankommt. Die günstigere und weitaus dauerhaftere Korrektur besteht darin, der Transformation ihre Freiheit vollständig zu nehmen: die Eingabe so einzuschränken, dass die Transformation die Identität ist.
// Lowercase alphanumeric ONLY, no hyphens. Forbidding hyphens makes prefix == slug,
// so distinct slugs can never share a data store or signing key.
if (!slug.All(c => (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')))
return false;
Slugs bestehen jetzt aus Kleinbuchstaben und Ziffern, sonst nichts. Ohne Bindestriche zum Entfernen hat GetTablePrefix nichts zu tun, prefix == slug gilt von Bauart her, und zwei verschiedene Slugs können nie wieder einen Namensraum teilen. Wir lehnen außerdem die reservierten Namen ab sowie jeden Slug, der auf admin oder sandbox endet, was die Kollision mit den System-Mandanten in derselben Zeile schließt. Die Gültigkeitsprüfung ist der enge Punkt, an dem ein Slug zum ersten Mal zu einem Namensraum wird, also gehört genau dorthin die Eins-zu-eins-Garantie.
Wir machen dann dieselbe Regel ein weiteres Mal geltend, am Engpass des Provisionings. Es gibt zwei Türen, durch die ein Slug eintreten kann, Self-Service-Registrierung und admingesteuertes Provisioning, und eine Sicherheitsinvariante, die nur an einer von zwei Türen verteidigt wird, ist an keiner verteidigt.
Warum wir es Ihnen erzählen
Wir haben das in einem Sicherheits-Audit unseres eigenen Produkts vor dem Launch gefunden, bevor ein einziger zahlender Kunde existierte, was der einzig akzeptable Zeitpunkt ist, es zu finden. Kein Mandant wurde jemals in Produktion zusammengeführt. Aber es ist ein demütigender Bug, denn er ist keine fehlende Prüfung und kein schwacher Algorithmus. Es ist eine Hilfsfunktion, die etwas völlig Vernünftiges tut, einen String bereinigen, an einer Stelle, an der sich herausstellt, dass "vernünftig" und "injektiv" verschiedene Wörter sind.
Die Lehre überlebte die Korrektur. Jede Funktion, die benutzergesteuerte Eingabe in den Namen einer Sicherheitsgrenze verwandelt, einer Tabelle, eines Schlüssels, eines Namensraums, eines Pfads, muss injektiv sein, und Sie sollten das am engsten Punkt erzwingen, an dem die Abbildung erzeugt wird, statt zu hoffen, dass sie jede nachgelagerte Schicht übersteht. Ein Normalisierungsschritt, lowercase, trim, strip, collapse, ist genau die Stelle, an der zwei Identitäten klammheimlich zu einer werden.
Es ist dasselbe Prinzip, auf dem das gesamte Produkt aufgebaut ist. Sicherheit ist keine Stufe, in die man aufsteigt; sie ist der Boden. Jede Isolationsgarantie, jeder Signaturschlüssel und jedes Sicherheitsfeature, das wir ausliefern, SSO und SAML, SCIM, MFA, Audit-Export, ist in jedem Tarif aktiv, denn die Alternative ist die Art von Sache, die man um 2 Uhr nachts findet statt in einem Audit. Sehen Sie, was enthalten ist.