Um `\n` no fim do nome da tabela furou a permissão do Datasette. Rodei o mesmo teste no meu controle de acesso
Esta semana saiu o Datasette 0.65.5. O changelog de segurança tem uma linha só: um \n no fim do nome da tabela pulava a checagem de permissão e devolvia linhas privadas. Simon Willison anunciou ontem, o registro está no aviso GHSA-h547-rmjf-5m2m, reportado por dpfkdlemtp, e a mesma correção entrou na 1.0a40.
Li a descrição e fui olhar meu próprio código antes de olhar o do Datasette. O buraco não mora no Datasette. Ele aparece em qualquer sistema que decide "pode ou não pode" pelo nome do recurso, e depois vai buscar o dado com uma versão levemente diferente desse nome.
A cena é a da portaria de prédio. O porteiro confere o nome na lista de bloqueados e não acha "salaries\n", porque na lista está "salaries". Libera. Lá dentro, a catraca lê o crachá, apara o espaço em branco e entra em "salaries". Mesma pessoa, duas leituras do mesmo nome. Quem autoriza usou uma string, quem entregou o dado usou outra.
O padrão que devolve a linha errada
Em código isso costuma ter esta cara:
PRIVATE = {"salaries", "customers"}
def get_rows(table: str, user):
# o portao
if table in PRIVATE and not user.is_admin:
raise Forbidden()
# a busca normaliza diferente do portao
real = table.strip() # some com o \n do fim
return db.execute(f'select * from "{real}"')
Peça table = "salaries\n". O teste "salaries\n" in PRIVATE dá falso, o portão abre. Aí real vira "salaries" e a query traz a tabela privada inteira. O portão e a busca canonicalizaram a mesma entrada em momentos diferentes, com regras diferentes. Essa distância é o furo.
Trailing newline é só o exemplo que caiu no Datasette. A mesma família tem vários primos: espaço no fim, \t, barra final (/), maiúscula contra minúscula, %0a que ainda não foi decodificado, forma unicode NFC contra NFD (o mesmo "ç" escrito de dois jeitos), byte nulo. Todos produzem a mesma coisa: uma string que o portão não reconhece como proibida e que a camada de dados resolve para o recurso proibido.
O roteiro que eu rodo
Antes de confiar, eu procuro. O caminho é curto.
Primeiro, listo todo lugar onde a autorização decide por nome, slug ou id em texto. grep por in PRIVATE, por denylist, por allowed, por role ==, por qualquer comparação de string que venha antes de um acesso a dado. O que interessa é o par: a linha que autoriza e a linha que busca. Se as duas recebem a mesma variável sem tocar nela, respiro. Se uma faz .strip(), .lower(), unquote() ou um cast e a outra não, marco.
Segundo, jogo os sufixos num teste parametrizado e mando ver:
import pytest
@pytest.mark.parametrize("suffix", ["", "\n", " ", "\t", "/", "%0a", "\x00"])
def test_tabela_privada_continua_privada(client, suffix):
resp = client.get(f"/db/salaries{suffix}.json")
# nao-admin: qualquer variacao tem que dar 403, nunca 200 com linha
assert resp.status_code in (403, 404), resp.text
Foram uns sete sufixos. O que eu quero ver é a variação que devolve 200 com dado dentro. Essa é a que fura. Se todas param em 403 ou 404, o portão e a busca estão falando a mesma língua.
Canonicalizar antes de decidir
O conserto não é sair aparando \n em toda entrada. Isso só empurra o problema para o próximo caractere que ninguém previu. A regra é normalizar uma vez, no começo, e autorizar sobre a forma canônica, a mesma que vai buscar o dado:
import unicodedata
def canonical(name: str) -> str:
return unicodedata.normalize("NFC", name).strip().lower()
def get_rows(table: str, user):
real = canonical(table) # normaliza UMA vez
if real in PRIVATE and not user.is_admin:
raise Forbidden()
return db.execute("select * from tables where name = ?", [real])
Melhor ainda quando dá: não autorize sobre a string crua, autorize sobre o objeto já resolvido. Resolva o recurso primeiro, pegue o registro real do banco, e cheque a permissão desse registro. Aí não sobra string solta para divergir, porque o que autoriza e o que entrega é a mesma linha do banco.
Isso é higiene barata perto do custo do vazamento. Estancar depois que uma linha privada saiu é sempre mais caro do que fechar o par portão-busca antes.
A pergunta
No seu sistema, quem autoriza e quem busca o dado usam exatamente a mesma string, ou tem um .strip() no meio do caminho que ninguém lembra que está lá? E você já viu bypass por normalização (unicode, case, barra final) na prática? Conta o caso, principalmente se o furo não foi trailing newline.