Docs›Segurança & Observabilidade›Hardening de filtros e ordenação (grid)
Segurança & Observabilidade

Hardening de filtros e ordenação (grid)

Allowlist de operadores (FILTER_OPS), coluna declarada, token MadStateCrypt no col-filter, binds no subselect, OrderGuard fail-closed no ORDER BY e cap de 500 binds no IN.

Hardening de filtros e ordenacao (grid)

Toda listagem do MAD (MadDataGrid) recebe do browser tres coisas que o PDO nao consegue bindar como parametro: nome de coluna, operador e clausula ORDER BY. Interpolar qualquer uma delas cruas em SQL e injection autenticada. O framework fecha esse caminho com allowlists (nunca blacklist de caracteres) em cinco camadas independentes, todas fail-closed: o que nao casa com a lista e descartado em silencio, nao "escapado".

1. Operador: allowlist fechada

O operador nunca chega ao SQL como string livre. MadDataGrid::FILTER_OPS e a lista completa do que existe:

protected const FILTER_OPS = [
    '=', '!=', '<>', '>', '>=', '<', '<=',
    'like', 'not like', 'in', 'not in',
    'between', 'date', 'date between',
    'is null', 'is not null',
];

_canonicalOp() normaliza aliases (eq, neq, gte, notlike, isnull...) e devolve '' para qualquer coisa fora da lista; _sanitizeOp($op, $fallback) cai no operador declarado pela coluna quando o valor recebido e invalido. Ou seja: o pior que um cliente hostil consegue e usar um operador legitimo diferente do default.

Fora do filter-op-select, o operador nem sequer vem do cliente — onFilter() usa o op derivado do tipo declarado da coluna. E o que faz filter="select" gerar = (em vez de LIKE '%valor%', que casava qualquer registro contendo o valor) e filter="date" gerar whereDate em vez de LIKE '%2026-07-28%'.

2. Coluna: so o que a grid declarou

onFilter($field, $value, $op) consulta _declaredFilter($field) — a coluna precisa ter declarado filterable ou filter-popover. Coluna nao declarada retorna null e a chamada retorna sem fazer nada:

// Nao filtra: a grid nunca expos a coluna, mesmo que ela exista na tabela.
$grid->onFilter('password_hash', 'a');

E a mesma whitelist usada pelo ORDER BY (_isSortableField), lida de exportColConfigs (prop publica, sobrevive ao ciclo AJAX) com fallback para _effectiveColumns().

3. Filtro de coluna: campo e operador viajam cifrados

O caminho moderno (onColFilter) nao aceita nome de coluna do cliente. Na renderizacao, cada coluna recebe um token MadStateCrypt:

$col->colFilterToken = MadStateCrypt::encrypt([
    'field' => $field,          // ja normalizado (_unbrace)
    'op'    => $op,             // ja canonicalizado (_canonicalOp)
    'kind'  => $col->filterKind,
]);

O browser devolve token + valor; onColFilter() faz MadStateCrypt::decrypt($token) e sai fora se o token nao decifrar ou nao trouxer field. O cliente nunca ve (nem escolhe) coluna, operador ou model.

4. Subselect: {value} vira bind, e o template e revalidado

Filtros com subselect (sub no token) sao o unico ponto em que a grid emite whereRaw. Mesmo vindo de token assinado, o valor passa por tres guardas de defesa em profundidade antes de chegar ao banco:

  • valor nao escalar (range/multi) e descartado — evitaria "Array to string conversion" e o bind literal 'Array';
  • o field precisa casar ^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$ (identificador simples ou qualificado);
  • o template sub e rejeitado se contiver ;, --, /*, */ ou \x00 (empilhamento de statement / comment injection). Template legitimo nunca os contem.

Passando os tres, o {value} vira bind posicional ? (inclusive dentro de literal com wildcard, '%{value}%'), nunca string escapada e inlined:

$q->whereRaw("{$f['field']} in ({$sub})", $binds);

5. ORDER BY: whitelist + OrderGuard fail-closed

Ordenacao passa por dois filtros em serie:

  1. _isSortableField($field) — a coluna precisa ter sido declarada sortable. Coluna de relacao cujo model vive em outra conexao perde o sortable automaticamente (_normalizeSortable), porque nao ha ORDER BY possivel ali.
  2. Mad\Database\OrderGuard::validate($orderExpr, 'ORDER BY') — fonte unica de verdade anti-injection para identificadores. Aceita, por termo: inteiro (posicao de GROUP BY), identificador, tabela.coluna, alias entre aspas duplas e agregado de 1 argumento (SUM/COUNT/AVG/MIN/MAX), com ASC/DESC opcional. Qualquer outra coisa lanca excecao e loga [SQL-INJECTION-GUARD] no servidor.

OrderGuard tambem cobre expressoes raw de SELECT/GROUP BY fora da grid (isSafeExpression()/validateExpression()), usado pelos charts que interpolam coluna ou funcao em selectRaw/groupByRaw.

6. Teto de binds no IN (...)

FILTER_IN_CAP = 500 limita a lista de um in/not in. Nao e so higiene: uma lista sem teto vinda do cliente derruba a conexao (Postgres aceita 65535 binds, SQL Server 2100). Ao truncar, a grid emite aviso no error_log (lista de filtro truncada em 500 itens) — o resultado pode estar incompleto.

_scalarList() ainda descarta itens nao escalares (array aninhado/objeto, que seriam bind invalido), vazios e duplicados antes de aplicar o cap.

Valor vazio nao e empty()

_filterValueIsEmpty() existe porque empty('0') e true: um filtro booleano com valor '0' (Nao) sumia em silencio. Vazio e null, '', [] ou array cujos itens sao todos vazios — range so e vazio quando os dois extremos sao.

Ver tambem

  • Observabilidade (logs) — onde o SQL efetivo pode ser auditado (mad_log_sql, opt-in).
  • Tracing (MadTrace) — captura de SQL no APM via DB::listen, com deteccao de N+1 e queries lentas.
  • MadDataGrid — declaracao de colunas, filterable e sortable.
  • Filtros da grid — o lado funcional (filter, filter-popover, filter-op-select, subselect).