Programação Por Equipe DevBras

Capa do artigo: SELECT * na API: a coluna criada no ano que vem vaza sozinha

SELECT * na API: a coluna criada no ano que vem vaza sozinha

Veja como um SELECT * ligado direto ao JSON expõe hash de senha, token e dados internos, e troque por lista de colunas, mapeamento de saída e um teste que trava o formato.

A página "Nossa equipe" do site da imobiliária mostra foto, nome e CRECI de cada corretor. Ela consome /api/corretores, que faz SELECT * FROM corretores e devolve tudo em JSON. Funcionou por dois anos. Aí o financeiro pediu duas colunas novas na tabela, cpf e comissao_percentual, e ninguém mexeu na API. No dia seguinte, qualquer visitante que abrisse a aba Rede do navegador via o CPF e a comissão de toda a equipe.

A resposta curta: SELECT * ligado direto à saída transforma toda coluna futura em dado público. Liste as colunas que a tela usa, monte a resposta campo a campo e tenha um teste que falha quando aparece um campo novo. O que não deve sair do servidor não pode nem entrar na variável que vai para o JSON.

Por que a estrela vaza mais do que parece

A estrela não significa "as colunas de hoje". Significa "todas as colunas que existirem no momento da consulta". O código não muda, mas o que ele devolve muda toda vez que alguém roda um ALTER TABLE. Quem acrescenta a coluna está pensando no relatório do financeiro, não numa API pública escrita por outra pessoa.

O código da imobiliária era o padrão dos tutoriais rápidos:

$corretores = $pdo->query('SELECT * FROM corretores')->fetchAll(PDO::FETCH_ASSOC);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($corretores);

Antes das colunas novas, a resposta já carregava senha_hash, token_recuperacao e observacao_interna. A página não mostrava esses campos, então ninguém notou. Mas "a tela não mostra" não protege nada: o JSON inteiro chega ao navegador, e qualquer pessoa lê com dois cliques.

O token_recuperacao é o pior deles. Com ele, alguém redefine a senha de um corretor sem acesso ao e-mail dele. O hash da senha é o segundo pior: vira alvo de quebra offline, sem limite de tentativas. É exatamente o tipo de vazamento que log sem senha trata no lado dos registros.

O mesmo acontece em HTML montado no servidor, quando o template recebe a linha inteira e alguém escreve data-usuario="<?= htmlspecialchars(json_encode($linha)) ?>" para "facilitar o JavaScript". E acontece com ORMs que serializam a entidade inteira por padrão.

Como fechar a saída em três camadas

Uma camada só falha no primeiro descuido. Na imobiliária, a correção ficou em três, e cada uma segura a anterior.

  1. Liste as colunas na consulta. A consulta passa a dizer o que a tela usa. Coluna nova na tabela não entra até alguém escolher colocá-la.
  2. Monte a resposta campo a campo. Mesmo que a consulta traga uma coluna a mais no futuro, a saída só tem os campos que o código escreveu. Aqui também se ajusta o formato, como transformar o caminho da foto em URL.
  3. Escreva um teste que trava as chaves. Ele compara a lista exata de campos do JSON. Campo novo faz o teste falhar e obriga alguém a decidir, com nome e data no commit.
  4. Restrinja no banco quando der. Um usuário do banco só para a parte pública, com acesso a uma view ou a colunas específicas, garante que nem um erro no PHP consegue ler a senha.
  5. Revise as respostas reais. Abra a aba Rede, filtre por Fetch/XHR e leia o JSON de cada tela pública. É o mesmo que um curioso faria.

O endpoint corrigido:

$stmt = $pdo->query(
    'SELECT id, nome, creci, foto_arquivo
       FROM corretores
      WHERE ativo = 1
      ORDER BY nome'
);

$saida = [];
foreach ($stmt as $linha) {
    $saida[] = [
        'id'    => (int) $linha['id'],
        'nome'  => $linha['nome'],
        'creci' => $linha['creci'],
        'foto'  => '/fotos/equipe/' . rawurlencode($linha['foto_arquivo']),
    ];
}

header('Content-Type: application/json; charset=utf-8');
echo json_encode($saida, JSON_UNESCAPED_UNICODE);

E o teste que trava o formato, em PHPUnit:

public function test_lista_publica_de_corretores_tem_so_os_campos_previstos(): void
{
    $resposta = $this->getJson('/api/corretores');
    $item = $resposta[0];

    $this->assertSame(['id', 'nome', 'creci', 'foto'], array_keys($item));
}

O getJson aqui representa o helper de requisição do seu framework de teste. O que importa é o assertSame com a lista fechada de chaves, na ordem em que o código as monta.

A trava no banco

O MySQL permite dar acesso a uma view ou a colunas específicas. Para o site público da imobiliária:

CREATE VIEW vw_corretores_publico AS
  SELECT id, nome, creci, foto_arquivo
    FROM corretores
   WHERE ativo = 1;

CREATE USER 'site_publico'@'10.0.0.%' IDENTIFIED BY 'gere-uma-senha-longa';
GRANT SELECT ON imobiliaria.vw_corretores_publico TO 'site_publico'@'10.0.0.%';

Com esse usuário, um SELECT * FROM corretores esquecido em algum canto dá erro de permissão em vez de vazar. O painel administrativo continua com outro usuário, que enxerga a tabela inteira.

Se você usa um ORM

No Laravel, a propriedade $hidden do model tira atributos do array e do JSON, e as API Resources deixam você escrever a saída campo a campo, como no PHP acima. No Entity Framework, projete para um DTO com Select em vez de devolver a entidade. A regra é a mesma em qualquer ferramenta: a saída pública é uma lista de permissões, nunca uma lista de exclusões. Lista de exclusão falha justamente no campo que ainda não existe.

E o desempenho?

A estrela também custa tempo: traz colunas TEXT e BLOB que a tela não usa, impede o banco de responder só pelo índice e infla a resposta. Esse lado, com medições, está em consultar só o que a tela precisa, e o problema de trazer linhas demais está em paginação que não pula. Aqui o foco é outro: mesmo numa tabela pequena e rápida, a estrela é uma decisão de segurança tomada sem ninguém perceber.

Há um efeito colateral menos conhecido. Num SELECT * com JOIN, as duas tabelas costumam ter uma coluna id. Com FETCH_ASSOC, a segunda sobrescreve a primeira no array, e o código passa a usar o id da tabela errada. Listar as colunas, com apelido quando o nome se repete, resolve o vazamento e esse bug juntos.

Erros comuns

  • Esconder o campo com CSS ou não usá-lo no JavaScript. O dado continua na resposta e qualquer pessoa lê no navegador.
  • Usar lista de exclusão, como unset($linha['senha_hash']). Protege a coluna de hoje e deixa passar a de amanhã.
  • Reaproveitar o endpoint do painel administrativo na página pública. O painel precisa de campos que o público não pode ver.
  • Devolver a entidade do ORM direto no controller. Relações carregadas e atributos internos vão junto para o JSON.
  • Usar o mesmo usuário de banco para o site público e para o painel. Um erro no código público passa a enxergar tudo.
  • SELECT * com JOIN e FETCH_ASSOC. Colunas com o mesmo nome se sobrescrevem e o código lê o valor errado sem erro nenhum.

Checklist

  • Nenhum endpoint público usa SELECT *.
  • A resposta de cada endpoint público é montada campo a campo.
  • Existe um teste que compara a lista exata de chaves do JSON público.
  • Hash de senha, token e anotação interna nunca aparecem na aba Rede de uma página pública.
  • O site público usa um usuário de banco com acesso só às views ou colunas necessárias.
  • Toda migração que cria coluna sensível tem uma linha no PR dizendo por onde ela não deve sair.

Abra agora a página pública mais visitada do seu sistema, vá à aba Rede e leia o JSON que ela recebe: há algum campo ali que você não colocaria num cartaz na porta da empresa?

Avalie este artigo

Referência: http://www.devmedia.com.br/php-e-mysql-conectando-e-exibindo-dados-de-forma-rapida-dica/28526