← All posts

Um hífen, dois tenants, uma única chave de assinatura

Authagonal·June 25, 2026
authsecuritymultitenancyisolationsaas

Dois dos nossos tenants eram o mesmo tenant. Eles tinham nomes diferentes, cadastros diferentes e linhas de cobrança diferentes. Eles também compartilhavam um banco de dados e uma chave de assinatura de tokens, e nenhum dos três de nós, os dois tenants ou nós, fazia a menor ideia. Esta é a história da função de uma linha que os fundiu, de por que toda camada do sistema parecia perfeitamente correta enquanto isso acontecia, e de por que uma auditoria pré-lançamento é o seguro mais barato que você vai comprar na vida.

A autenticação multi-tenant tem exatamente uma tarefa que não pode errar: manter os tenants separados. Os usuários da Acme, as sessões da Acme e, acima de tudo, a chave de assinatura da Acme nunca podem ficar ao alcance de ninguém mais. A chave de assinatura é a joia da coroa. Quem consegue assinar com a chave da Acme pode forjar um token que o próprio servidor de autenticação da Acme aceitará como genuíno, para qualquer usuário, com qualquer função, sem senha alguma. Por isso, cada recurso por tenant que criamos, cada tabela de armazenamento e cada chave, recebe um namespace pelo slug do tenant. Acerte esse namespace e os tenants viram ilhas. Erre de forma sutil e eles silenciosamente passam a ser o mesmo lugar.

O bug de uma linha

Aqui está a função que transforma um slug de tenant no prefixo com que nomeamos seu armazenamento e suas chaves. Leia o comentário de documentação. Ele documenta o bug como se fosse uma funcionalidade.

/// 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("-", ""). Ele remove hífens. A intenção era de arrumação: slugs fluem para nomes de Azure Table e nomes de chave do Vault, que têm suas próprias regras de caracteres, então nós os higienizávamos. O problema é que remover caracteres é uma transformação com perda, e uma transformação com perda sobre um identificador não é injetiva. acme-corp e acmecorp resultam ambos em acmecorp. O mesmo vale para ac-me-corp e acme--corp. Slugs distintos, um único namespace.

Esse namespace é tudo o que vem depois. A tabela de usuários é {prefix}-Users. E a chave de assinatura por tenant é, literalmente, esta:

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

Então acme-corp e acmecorp não apenas compartilham uma lista de usuários. Eles assinam seus tokens com a mesma chave signing-acmecorp no Vault. Um token forjado para um é, byte por byte, um token validamente assinado para o outro. Se você consegue registrar um slug que colapsa para o mesmo prefixo de um tenant existente, você consegue emitir para si mesmo tokens em que o servidor de autenticação deles confia plenamente. Sequestro de conta entre tenants, e o exploit é "cadastre-se com um hífen".

Por que nada detectou isso

A parte perturbadora é o quão comum cada camada parecia. O cadastro validava o slug e via uma string nova, sem uso. O provisionamento criava um registro de tenant chaveado pelo slug completo, acme-corp, que é genuinamente distinto de acmecorp no plano de controle. A validação de token derivava a chave a partir do slug e verificava tranquilamente. Cada componente fazia o seu trabalho corretamente sobre a sua própria entrada.

Nada no sistema jamais comparava os prefixos de dois slugs, porque nenhum componente isolado era dono da invariante "um slug mapeia para exatamente um namespace". A colisão vivia na lacuna entre um slug, em que o plano de controle se baseia, e um prefixo, em que o armazenamento e o Vault se baseiam. Ninguém estava de pé nessa lacuna. Esta é a assinatura da classe mais perigosa de bug: não uma verificação que alguém esqueceu, mas uma suposição que ninguém sabia que estava fazendo.

A segunda colisão, de graça

A mesma transformação com perda tinha uma segunda vítima. Nossos tenants internos de sistema são nomeados por sufixo: {slug}-admin guarda o time de portal de um tenant, {slug}-sandbox guarda seu ambiente de teste. Passe esses pelo mesmo removedor de hífens e acme-admin vira acmeadmin. O que significa que um cliente que registrasse o slug acmeadmin colapsaria sobre o prefixo do tenant admin da Acme, justamente o lugar que deveria ser mais privilegiado do que o cliente, não compartilhado com ele.

Uma função de remoção, dois limites de isolamento diferentes em risco: tenant contra tenant, e tenant contra o seu próprio plano de controle. Quando uma única linha ameaça dois limites não relacionados, esse é o indício de um bug de causa raiz, e não de um bug de superfície. A correção precisa pousar sobre a transformação, não sobre nenhum dos dois sintomas.

A correção: injetiva por construção

O instinto é tornar a função de prefixo mais esperta. Escapar os hífens, fazer hash do slug, codificá-lo em base32. Cada uma dessas é, ainda, uma transformação que você precisa provar injetiva para sempre, contra toda mudança futura, por alguém que talvez não saiba por que isso importa. A correção mais barata e muito mais durável é remover por completo a liberdade da transformação: restrinja a entrada de modo que a transformação seja a identidade.

// 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;

Os slugs agora são letras minúsculas e dígitos, nada mais. Sem hífens para remover, GetTablePrefix não tem nada a fazer, prefix == slug vale por construção, e dois slugs distintos nunca mais podem compartilhar um namespace. Também rejeitamos os nomes reservados e qualquer slug terminado em admin ou sandbox, o que fecha a colisão de tenant de sistema na mesma linha. A verificação de validade é o ponto estreito onde um slug primeiro se torna um namespace, então é exatamente ali que a garantia de um para um deve morar.

Em seguida, reafirmamos a mesma regra mais uma vez, no ponto de estrangulamento do provisionamento. Há duas portas pelas quais um slug pode entrar, o cadastro self-service e o provisionamento conduzido por admin, e uma invariante de segurança defendida em apenas uma das duas portas não está defendida em nenhuma.

Por que estamos contando isso a você

Encontramos isso em uma auditoria de segurança pré-lançamento do nosso próprio produto, antes de existir um único cliente pagante, que é o único momento aceitável para encontrar. Nenhum tenant jamais foi fundido em produção. Mas é um bug humilhante, porque não é uma verificação ausente nem um algoritmo fraco. É um helper que faz algo inteiramente razoável, higienizar uma string, em um lugar onde "razoável" e "injetivo" acabam sendo palavras diferentes.

A lição sobreviveu à correção. Qualquer função que transforme entrada controlada pelo usuário no nome de um limite de segurança, uma tabela, uma chave, um namespace, um caminho, precisa ser injetiva, e você deve impor isso no ponto mais estreito onde o mapeamento é criado, e não torcer para que ele sobreviva a cada camada seguinte. Um passo de normalização, lowercase, trim, strip, collapse, é precisamente onde duas identidades silenciosamente viram uma.

É o mesmo princípio sobre o qual o produto inteiro é construído. Segurança não é um nível para o qual você se gradua; é o piso. Toda garantia de isolamento, toda chave de assinatura e todo recurso de segurança que entregamos, SSO e SAML, SCIM, MFA, exportação de auditoria, está ligado em todos os planos, porque a alternativa é o tipo de coisa que você descobre às 2h da manhã em vez de em uma auditoria. Veja o que está incluído.