InícioDesafiosDesafio 02 › Gabarito

Gabarito · Desafio 02 Nível: Intermediário

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 LIKE ou 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.Name exige 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.