← All posts

Een koppelteken, twee huurders, een ondertekeningsleutel

Authagonal·June 25, 2026
authsecuritymultitenancyisolationsaas

Twee van ons huurders was dieselfde huurder. Hulle het verskillende name, verskillende registrasies en verskillende faktureringsreëls gehad. Hulle het ook 'n databasis en 'n token-ondertekeningsleutel gedeel, en nie een van die drie van ons, die twee huurders of ons, het enige idee gehad nie. Dit is die verhaal van die een-reël-funksie wat hulle saamgevoeg het, hoekom elke laag van die stelsel volkome korrek gelyk het terwyl dit gebeur het, en hoekom 'n voor-lansering-oudit die goedkoopste versekering is wat jy ooit sal koop.

Multi-huurder-stawing het presies een taak wat dit nie verkeerd kan kry nie: hou huurders uitmekaar. Acme se gebruikers, Acme se sessies, en bowenal Acme se ondertekeningsleutel mag nooit deur enigiemand anders bereikbaar wees nie. Die ondertekeningsleutel is die kroonjuweel. Wie ook al met Acme se sleutel kan onderteken, kan 'n token skep wat Acme se eie stawingsbediener as eg sal aanvaar, vir enige gebruiker, met enige rol, geen wagwoord nodig nie. So elke per-huurder-hulpbron wat ons skep, elke stoortabel en elke sleutel, kry sy naamruimte uit die huurder se slug. Kry daardie naamruimtes reg en huurders is eilande. Kry dit subtiel verkeerd en hulle word stilweg dieselfde plek.

Die een-reël-fout

Hier is die funksie wat 'n huurder-slug in die voorvoegsel verander waarmee ons sy stoor en sleutels benoem. Lees die dokumentasiekommentaar. Dit dokumenteer die fout asof dit 'n kenmerk is.

/// 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("-", ""). Dit verwyder koppeltekens. Die bedoeling was opruiming: slugs vloei in Azure Table-name en Vault-sleutelname in, wat hul eie karakterreëls het, so ons het hulle gesuiwer. Die probleem is dat die verwydering van karakters 'n verlieslike transformasie is, en 'n verlieslike transformasie op 'n identifiseerder is nie injektief nie. acme-corp en acmecorp kom altwee uit as acmecorp. So ook ac-me-corp en acme--corp. Verskillende slugs, een naamruimte.

Daardie naamruimte is alles stroomaf. Die gebruikertabel is {prefix}-Users. En die per-huurder-ondertekeningsleutel is, woordeliks, dít:

private string GetKeyName() => $"signing-{ShardRouter.GetTablePrefix(_tenantContext.Slug)}";

So acme-corp en acmecorp deel nie net 'n gebruikerslys nie. Hulle onderteken hul tokens met dieselfde signing-acmecorp-sleutel in Vault. 'n Token wat vir die een geskep is, is, greep vir greep, 'n geldig-ondertekende token vir die ander. As jy 'n slug kan registreer wat tot dieselfde voorvoegsel as 'n bestaande huurder saamval, kan jy vir jouself tokens uitreik wat hul stawingsbediener volkome vertrou. Kruis-huurder-rekeningoorname, en die uitbuiting is "registreer met 'n koppelteken."

Hoekom niks dit gevang het nie

Die ontstellende deel is hoe gewoon elke laag gelyk het. Registrasie het die slug gevalideer en 'n vars, ongebruikte string gesien. Voorsiening het 'n huurderrekord geskep wat met die volle slug, acme-corp, gesleutel is, wat werklik van acmecorp in die beheervlak verskil. Tokenvalidasie het die sleutel uit die slug afgelei en tevrede geverifieer. Elke komponent het sy werk korrek op sy eie invoer gedoen.

Niks in die stelsel het ooit twee slugs se voorvoegsels vergelyk nie, want geen enkele komponent het die invariant "'n slug karteer na presies een naamruimte" besit nie. Die botsing het in die gaping tussen 'n slug, waarop die beheervlak sleutel, en 'n voorvoegsel, waarop stoor en Vault sleutel, gewoon. Niemand het in daardie gaping gestaan nie. Dit is die kenmerk van die gevaarlikste klas fout: nie 'n kontrole wat iemand vergeet het nie, maar 'n aanname wat niemand geweet het hulle maak nie.

Die tweede botsing, gratis

Dieselfde verlieslike transformasie het 'n tweede slagoffer gehad. Ons interne stelselhuurders word volgens agtervoegsel benoem: {slug}-admin hou 'n huurder se portaalspan, {slug}-sandbox hou sy toetsomgewing. Laat loop dít deur dieselfde koppelteken-verwyderaar en acme-admin word acmeadmin. Wat beteken 'n klant wat die slug acmeadmin geregistreer het, sou op die voorvoegsel van Acme se admin-huurder saamval, die een plek wat veronderstel is om meer bevoorreg as die klant te wees, nie met een gedeel te word nie.

Een verwyderingsfunksie, twee verskillende isolasiegrense in gevaar: huurder tot huurder, en huurder tot sy eie beheervlak. Wanneer 'n enkele reël twee onverwante grense bedreig, is dit die teken van 'n grondoorsaak-fout eerder as 'n oppervlakkige een. Die regstelling moet op die transformasie land, nie op een van die simptome nie.

Die regstelling: injektief deur konstruksie

Die instink is om die voorvoegsel-funksie slimmer te maak. Ontsnap die koppeltekens, hash die slug, base32-enkodeer dit. Elkeen daarvan is steeds 'n transformasie wat jy vir altyd injektief moet bewys, teen elke toekomstige verandering, deur iemand wat dalk nie weet hoekom dit saak maak nie. Die goedkoper en veel duursamer regstelling is om die transformasie se vryheid heeltemal te verwyder: beperk die invoer sodat die transformasie die identiteit is.

// 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 is nou kleinletters en syfers, niks anders nie. Met geen koppeltekens om te verwyder nie, het GetTablePrefix niks te doen nie, prefix == slug geld deur konstruksie, en twee verskillende slugs kan nooit weer 'n naamruimte deel nie. Ons verwerp ook die gereserveerde name en enige slug wat op admin of sandbox eindig, wat die stelselhuurder-botsing in dieselfde reël toemaak. Die geldigheidstoets is die nou punt waar 'n slug eers 'n naamruimte word, so dít is presies waar die een-tot-een-waarborg hoort.

Ons herbevestig dan dieselfde reël nog een keer, by die voorsienings-knelpunt. Daar is twee deure waardeur 'n slug kan inkom, selfdiens-registrasie en admin-gedrewe voorsiening, en 'n sekuriteitsinvariant wat by slegs een van twee deure verdedig word, word by nie een verdedig nie.

Hoekom ons jou dit vertel

Ons het dit in 'n voor-lanserings-sekuriteitsoudit van ons eie produk gevind, voordat 'n enkele betalende klant bestaan het, wat die enigste aanvaarbare tyd is om dit te vind. Geen huurder is ooit in produksie saamgevoeg nie. Maar dit is 'n fout wat 'n mens nederig maak, want dit is nie 'n ontbrekende kontrole of 'n swak algoritme nie. Dit is 'n hulpfunksie wat iets heeltemal redeliks doen, suiwer 'n string, op 'n plek waar "redelik" en "injektief" blyk verskillende woorde te wees.

Die les het die regstelling oorleef. Enige funksie wat gebruiker-beheerde invoer in die naam van 'n sekuriteitsgrens verander, 'n tabel, 'n sleutel, 'n naamruimte, 'n pad, moet injektief wees, en jy behoort dit af te dwing by die nouste punt waar die kartering geskep word, nie te hoop dat dit elke laag stroomaf oorleef nie. 'n Normaliseringstap, kleinletter maak, afkap, stroop, saamvou, is presies waar twee identiteite stilweg een word.

Dit is dieselfde beginsel waarop die hele produk gebou is. Sekuriteit is nie 'n vlak waarin jy gradueer nie; dit is die vloer. Elke isolasiewaarborg, elke ondertekeningsleutel, en elke sekuriteitskenmerk wat ons lewer, SSO en SAML, SCIM, MFA, oudituitvoer, is aan op elke plan, want die alternatief is die soort ding wat jy om 02:00 vind in plaas van in 'n oudit. Sien wat ingesluit is.