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
fieldprecisa casar^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$(identificador simples ou qualificado); - o template
sube 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:
_isSortableField($field)— a coluna precisa ter sido declaradasortable. Coluna de relacao cujo model vive em outra conexao perde osortableautomaticamente (_normalizeSortable), porque nao haORDER BYpossivel ali.Mad\Database\OrderGuard::validate($orderExpr, 'ORDER BY')— fonte unica de verdade anti-injection para identificadores. Aceita, por termo: inteiro (posicao deGROUP BY), identificador,tabela.coluna, alias entre aspas duplas e agregado de 1 argumento (SUM/COUNT/AVG/MIN/MAX), comASC/DESCopcional. 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,
filterableesortable. - Filtros da grid — o lado funcional
(
filter,filter-popover,filter-op-select, subselect).