InícioDesafios › Desafio 02

Desafio 02 Nível: Intermediário

Filtro dinâmico sem abrir a porta para injection

O desafio anterior era sobre volume. Este é sobre confiança: o momento em que o seu código passa a receber texto que veio de fora e usar esse texto para montar uma consulta. É aqui que mora a vulnerabilidade mais clássica de Apex, e também um erro mais silencioso que ela: a consulta que funciona, mas devolve para o usuário um campo que ele não deveria enxergar.

SOQL dinâmico Schema describe Bind variables FLS USER_MODE Programação defensiva

O cenário

A equipe de atendimento pediu uma busca flexível de contas: em vez de uma tela com campos fixos, a pessoa escolhe num menu qual campo quer filtrar e digita o valor. Hoje pode ser por nome, amanhã por CNPJ, depois por um campo personalizado que ainda não existe.

Como o campo é escolhido em tempo de execução, não dá para escrever a consulta pronta: ela precisa ser montada em texto. A primeira versão entregue foi mais ou menos assim, e é exatamente o que você não vai fazer:

// NAO faca isso: o valor digitado entra direto na consulta
String soql = 'SELECT Id, Name FROM Account WHERE ' + campo + ' = \'' + valor + '\'';
return Database.query(soql);

Se alguém digitar no campo de busca algo como x' OR Name != null OR Name = 'y, o filtro deixa de filtrar e a consulta devolve a base inteira. E se o menu for manipulado para pedir um campo sensível, a tela passa a exibir dado que aquele usuário não tem permissão de ler.

O que você deve construir

Crie uma classe Apex chamada BuscaSegura com um método público e estático, exatamente com esta assinatura:

public static List<Account> buscarPorCampo(String campoApi, String valor)

O método deve receber o nome de API de um campo de Account e um valor, e devolver as contas em que aquele campo é igual àquele valor.

Regras de aceite

  • O valor nunca entra na string da consulta. Ele precisa chegar ao SOQL por bind.
  • O campo é validado contra os campos reais do objeto, lidos do schema em tempo de execução. Não vale manter uma lista de campos permitidos escrita na mão: ela envelhece no dia em que alguém criar um campo novo.
  • Campo que o usuário não pode ler não é consultado. A consulta precisa respeitar as permissões de quem chamou o método, não rodar com acesso total.
  • Campo inexistente, campo inacessível, valor nulo ou entrada em branco devolvem lista vazia, sem lançar exceção.
  • No máximo 50 registros por chamada.

O que este desafio avalia

  • Se você sabe a diferença entre o que pode ser parametrizado por bind (valores) e o que não pode (nomes de campo, nomes de objeto).
  • Se você usa o próprio schema como fonte de verdade em vez de uma lista fixa.
  • Se você lembra que segurança de dados em Apex tem duas camadas: a injection e a permissão de leitura.
  • Se o seu método falha de forma previsível, devolvendo vazio em vez de estourar erro na tela.
Travei. Me dá uma direção (sem entregar a resposta)

O desafio se resolve respondendo três perguntas na ordem:

  • Como saber, em tempo de execução, se um campo existe no objeto? Pesquise por "Schema SObjectType fields getMap Apex". Atenção a um detalhe que pega muita gente: as chaves desse mapa vêm em minúsculas.
  • Como saber se o usuário pode ler aquele campo? A partir do campo você chega no describe dele. Procure "DescribeFieldResult isAccessible". Vale checar também se o campo é filtrável, porque nem todo tipo de campo pode entrar num WHERE.
  • Como passar o valor sem concatenar? Procure "Apex dynamic SOQL bind variable". Uma variável local em escopo pode ser referenciada com dois-pontos dentro da string da consulta.

Para a camada de permissão da consulta inteira, procure por "WITH USER_MODE SOQL". É uma cláusula que você acrescenta na própria consulta.

Como testar

Rode no Execute Anonymous. Os três primeiros casos são o uso normal; os três últimos são as tentativas de ataque e de erro que o seu método precisa absorver sem quebrar:

// 1. Caminho normal: campo padrao existente
System.debug('Por Name: ' + BuscaSegura.buscarPorCampo('Name', 'Acme'));

// 2. Case do nome do campo nao deve importar
System.debug('Por name minusculo: ' + BuscaSegura.buscarPorCampo('name', 'Acme'));

// 3. Outro campo padrao qualquer
System.debug('Por Industry: ' + BuscaSegura.buscarPorCampo('Industry', 'Technology'));

// 4. Tentativa de injection: precisa voltar vazio ou filtrar de verdade, nunca a base toda
List<Account> ataque = BuscaSegura.buscarPorCampo('Name', 'x\' OR Name != null OR Name = \'y');
System.debug('Tentativa de injection devolveu: ' + ataque.size() + ' registros');
System.assert(ataque.size() < 50 || ataque.isEmpty(), 'Injection nao pode devolver a base inteira');

// 5. Campo que nao existe
System.assertEquals(0, BuscaSegura.buscarPorCampo('CampoQueNaoExiste__c', 'x').size(), 'Campo inexistente deve voltar vazio');

// 6. Entradas degeneradas
System.assertEquals(0, BuscaSegura.buscarPorCampo(null, 'x').size(), 'Campo nulo deve voltar vazio');
System.assertEquals(0, BuscaSegura.buscarPorCampo('Name', null).size(), 'Valor nulo deve voltar vazio');
System.debug('Todos os casos passaram');

Gabarito comentado

A solucao completa com as duas camadas de protecao explicadas, a pegadinha do nome canonico, os erros que deixam a brecha aberta e como validar que a sua versao esta segura.

Ver o gabarito

O ponto que fica: toda vez que texto de fora entra no seu código, ele precisa passar por uma porta. Valor passa pela porta do bind. Nome de campo e de objeto passam pela porta do describe. O que não passa por porta nenhuma é o que vira incidente.