Início › Desafios › Desafio 02 › Gabarito
Gabarito: filtro dinâmico sem abrir a porta para injection
Este gabarito vai além do código: ele explica por que cada proteção existe e o que acontece quando você esquece cada uma. A parte mais valiosa não é a solução em si, é entender que aqui existem duas vulnerabilidades diferentes, e que resolver uma não resolve a outra.
Spoiler à frente. A solução completa está logo abaixo. Voltar para o desafio
As duas vulnerabilidades deste cenário
Quase todo mundo enxerga a primeira e ignora a segunda:
- Injection: o valor digitado quebra a consulta e devolve o que não deveria. É a famosa, a que aparece em toda lista de boas práticas.
- Vazamento por permissão: a consulta funciona perfeitamente, sem nenhuma injection, e mesmo assim entrega ao usuário o conteúdo de um campo que ele não tem permissão de ler. Esta é a silenciosa, e é mais comum que a primeira, porque o Apex ignora FLS e sharing por padrão.
Uma solução que só usa bind resolve a primeira e deixa a segunda aberta. Por isso as regras de aceite cobravam as duas.
A solução completa
public with sharing class BuscaSegura {
private static final Integer LIMITE = 50;
public static List<Account> buscarPorCampo(String campoApi, String valor) {
List<Account> vazio = new List<Account>();
// Entrada degenerada sai antes de qualquer trabalho.
if (String.isBlank(campoApi) || valor == null) {
return vazio;
}
// A lista de campos permitidos vem do proprio schema, nao de uma
// constante escrita na mao. Campo novo passa a funcionar sozinho.
// Atencao: as chaves de getMap() vem em minusculas.
Map<String, Schema.SObjectField> campos = Schema.SObjectType.Account.fields.getMap();
Schema.SObjectField campo = campos.get(campoApi.toLowerCase());
if (campo == null) {
return vazio;
}
// Segunda camada: o usuario precisa poder LER o campo, e o campo
// precisa ser filtravel para entrar num WHERE.
Schema.DescribeFieldResult info = campo.getDescribe();
if (!info.isAccessible() || !info.isFilterable()) {
return vazio;
}
// O nome que vai para a string e o nome canonico devolvido pelo
// describe, nao o texto que o usuario mandou.
String nomeCanonico = info.getName();
// O nome do campo e concatenado (ja validado). O VALOR entra por bind.
String soql =
'SELECT Id, Name FROM Account ' +
'WHERE ' + nomeCanonico + ' = :valor ' +
'WITH USER_MODE ' +
'LIMIT ' + LIMITE;
try {
return Database.query(soql);
} catch (QueryException e) {
// Tipo incompativel entre campo e valor, por exemplo.
return vazio;
}
}
}
Destrinchando parte por parte
1. Por que o valor vai por bind e o campo não
'WHERE ' + nomeCanonico + ' = :valor '
Esta linha resume o desafio inteiro, e ela parece contraditória: por que uma parte é concatenada e a outra não?
Porque o SOQL não aceita bind em nome de campo. Você não pode escrever
WHERE :nomeDoCampo = :valor. O nome do campo faz parte da estrutura da consulta, e estrutura
precisa ser texto. Já o valor é dado, e dado tem bind.
Como o nome do campo é obrigatoriamente concatenado, ele fica exposto. É exatamente por isso que ele precisa passar pela validação do describe antes: a validação faz o papel que o bind faria. Nenhuma das duas proteções é opcional, elas cobrem partes diferentes da consulta.
2. Por que o schema substitui a lista de campos permitidos
Map<String, Schema.SObjectField> campos = Schema.SObjectType.Account.fields.getMap();
Schema.SObjectField campo = campos.get(campoApi.toLowerCase());
if (campo == null) {
return vazio;
}
A alternativa óbvia seria manter uma constante com os campos aceitos:
Set<String> PERMITIDOS = new Set<String>{'Name', 'Industry'}. Parece até mais
seguro, porque é mais restritivo.
O problema é o que acontece depois. No dia em que alguém criar Segmento__c e a busca não
funcionar, a pessoa que for consertar não vai conhecer o histórico dessa lista. Ela vai ver uma validação
atrapalhando e o caminho mais rápido para "resolver" é remover a validação. A whitelist estática cria o
incentivo para ser desativada.
A whitelist dinâmica não tem esse problema: campo novo funciona automaticamente, e ninguém precisa mexer na proteção. Segurança que atrapalha o dia a dia acaba desligada; segurança invisível sobrevive.
A pegadinha: as chaves de getMap() vêm em minúsculas. Sem o
toLowerCase(), buscar por 'Name' não encontra nada e o método devolve vazio
sempre, sem erro nenhum, o que é péssimo de depurar.
3. A pegadinha do nome canônico
String nomeCanonico = info.getName();
Detalhe pequeno com consequência grande. Depois de validar, é tentador concatenar direto o
campoApi que veio por parâmetro, afinal ele "já foi validado".
Só que você validou a versão em minúsculas dele, e vai concatenar o texto original. São strings
diferentes. Usar info.getName() garante que o que entra na consulta é o nome real do campo
segundo o schema, não o texto que veio de fora. A regra geral: valide uma coisa e use essa mesma
coisa, nunca valide uma e use outra.
4. As duas checagens do describe
if (!info.isAccessible() || !info.isFilterable()) {
return vazio;
}
isAccessible() é a proteção de permissão em nível de campo: responde "este usuário pode ler
este campo?". Sem ela, um usuário sem acesso ao campo consegue usá-lo como filtro e inferir o conteúdo
pelo resultado da busca, mesmo sem ver o campo na tela.
isFilterable() é sobre viabilidade técnica: campos de texto longo, por exemplo, não podem
entrar num WHERE. Sem essa checagem o método lançaria exceção em vez de devolver vazio,
violando a regra de aceite.
5. WITH USER_MODE
As checagens do describe protegem o campo do filtro. O WITH USER_MODE protege a consulta
inteira: aplica FLS e regras de compartilhamento em todos os campos retornados e em todos os registros.
É a diferença entre "o usuário não pode filtrar por esse campo" e "o usuário não pode ver esses registros". Sem essa cláusula, sua busca pode devolver contas que ele não deveria enxergar, mesmo que o filtro esteja perfeito.
Erros comuns
- Achar que
String.escapeSingleQuotes()resolve. Ela escapa aspas no valor, o que ajuda, mas é um remendo que depende de você lembrar de chamá-la em todo lugar. O bind não depende de memória: ou você usa, ou o código não compila do jeito que você quis. - Proteger contra injection e esquecer a permissão. O código fica tecnicamente seguro contra ataque e continua vazando dado no uso normal. É o erro mais frequente entre quem já sabe o básico.
- Whitelist estática de campos. Funciona hoje, quebra no primeiro campo novo e acaba sendo removida por quem for consertar.
- Validar o campo e concatenar o texto original. Anula a validação de forma sutil.
- Consultar sem
LIMIT. Filtro por um valor muito comum traz milhares de registros e estoura o limite de heap. - Deixar a exceção subir. Uma tela de busca que mostra stack trace de Apex para o usuário final é falha de produto, além de expor detalhe interno do sistema.
Como confirmar que a sua solução está segura
O teste do desafio já cobre os casos principais. O mais importante é o quarto: se a tentativa de injection devolver a base inteira, o filtro foi neutralizado.
// Se este assert falhar, o valor esta entrando na consulta como codigo
List<Account> ataque = BuscaSegura.buscarPorCampo('Name', 'x\' OR Name != null OR Name = \'y');
System.assert(ataque.isEmpty() || ataque.size() < 50, 'Injection neutralizou o filtro');
// Se este devolver resultado, a validacao de campo nao esta funcionando
System.assertEquals(0, BuscaSegura.buscarPorCampo('CampoInventado__c', 'x').size());
Com a solução acima, o texto malicioso é tratado como valor literal: o Salesforce procura uma conta cujo nome seja exatamente aquela string esquisita, não encontra nenhuma, e devolve lista vazia. O ataque vira uma busca sem resultado.
Um passo além
- Vários filtros combinados. O desafio pede um campo só. Na vida real a tela costuma ter três ou quatro filtros combinados, e aí a montagem da consulta fica bem mais delicada. O raciocínio completo está em SOQL dinâmico sem SQL injection.
-
Operadores além de igualdade. Permitir
LIKEou intervalos exige validar também o operador, com a mesma lógica de whitelist. Nunca aceite o operador direto do parâmetro. -
Campos de objetos relacionados. Filtrar por
Owner.Nameexige percorrer o describe do relacionamento. É o mesmo princípio, com um nível a mais.
O que fica deste desafio: texto que vem de fora precisa passar por uma porta. Valor passa pela porta do bind. Nome de campo passa pela porta do describe. Permissão passa pela porta do USER_MODE. O que não passa por porta nenhuma é o que vira incidente de segurança.