2

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.

Carregando publicação patrocinada...
1

Imagina se eu cadastro meu nome como "Joao\0" ou "Joao\r\nMaria\r\n"... se o Firmware do equipamento não estiver preparado, provavelmente vai dar chabú.

1
import unicodedata

PRIVATE = {"salaries", "customers"}

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])
    
    db.execute("select * from tables where name = ?", [real])

Ainda tem uma falha, passe table como "salaries ;-- " e veja a segurança indo pro ralo, se realmente quer corrigir assim, ANTES verifique se a tabela existe:

if real not in DB_TABLES:
    raise Forbidden()

No entanto a melhor abordagem nesse modelo é, além de checar se a tabela existe, ter a política: NENHUMA tabela é selecionável por padrão, apenas as explicitamente definidas como selecionáveis podem ser selecionadas

Se puder usar views prontas é melhor ainda