Preventing Filter Injection in Query Parsers
This page belongs to Advanced Filtering Operators in the Query Patterns & Data Shaping Strategies reference. A filter parameter is the largest attack surface a read endpoint has: it accepts a small language, and every language accepted from an untrusted source needs a parser, an allowlist and a cost budget rather than string concatenation and hope.
The decision trigger
| Symptom | Risk |
|---|---|
| Filter values interpolated into SQL strings | classic SQL injection |
| Sort or filter field names used as column names directly | identifier injection — parameters cannot help |
| A filter can reference a field not in the response schema | data exposure through inference |
Deeply nested AND/OR accepted without limit |
parser or planner exhaustion |
LIKE '%term%' allowed on any text column |
full scans on demand |
| Database error text returned to the caller | schema disclosure |
Spec snippet: declare what is filterable
# openapi.yaml (OpenAPI 3.1.0) — the allowlist is part of the contract
paths:
/invoices:
get:
operationId: listInvoices
parameters:
- name: filter
in: query
description: >
RSQL expression over the filterable fields. Supported fields:
`status`, `currency`, `total_minor`, `created_at`, `customer_id`.
Supported operators: `==`, `!=`, `=gt=`, `=ge=`, `=lt=`, `=le=`,
`=in=`, `=out=`, combined with `;` (and) and `,` (or).
schema:
type: string
maxLength: 512 # a hard bound before parsing begins
examples:
simple: { value: "status==paid" }
complex: { value: "status=in=(paid,open);total_minor=gt=1000" }
responses:
"422":
$ref: "#/components/responses/ValidationFailed"
maxLength on the parameter schema is the cheapest control available: it bounds the parser’s input before any parsing begins, so a megabyte of nested parentheses never reaches the tokeniser.
Step-by-step
Step 1 — Define the allowlist as data, next to the resource
// filterable.ts — the single source of truth for what may be filtered
export interface FilterableField {
/** The column expression. NEVER derived from user input. */
column: string;
type: "string" | "integer" | "money" | "uuid" | "timestamp" | "enum";
operators: ReadonlyArray<Operator>;
enumValues?: readonly string[];
/** Relative cost; expensive operators need a budget. */
cost?: number;
}
export const INVOICE_FILTERS: Record<string, FilterableField> = {
status: { column: "i.status", type: "enum", operators: ["==", "!=", "=in="], enumValues: ["draft", "open", "paid", "void"] },
currency: { column: "i.currency", type: "string", operators: ["==", "=in="] },
total_minor: { column: "i.total_minor", type: "integer", operators: ["==", "!=", "=gt=", "=ge=", "=lt=", "=le="] },
created_at: { column: "i.created_at", type: "timestamp", operators: ["=gt=", "=ge=", "=lt=", "=le="] },
customer_id: { column: "i.customer_id", type: "uuid", operators: ["==", "=in="] },
// Deliberately absent: internal_notes, risk_score, deleted_at.
};
Keeping the map next to the resource makes the omissions reviewable. A field that is not listed cannot be filtered, cannot be inferred by binary search over the result count, and cannot appear in a WHERE clause by accident — which is a stronger property than “we validated the input”.
Step 2 — Parse, then validate the tree
// compile-filter.ts — AST in, parameterised SQL out
type Node =
| { kind: "and" | "or"; children: Node[] }
| { kind: "cmp"; field: string; op: Operator; value: string | string[] };
const MAX_DEPTH = 4;
const MAX_TERMS = 20;
const COST_BUDGET = 40;
export function compileFilter(ast: Node, fields = INVOICE_FILTERS) {
const params: unknown[] = [];
let terms = 0;
let cost = 0;
function walk(node: Node, depth: number): string {
if (depth > MAX_DEPTH) throw new FilterError("/filter", "too_deep", { max: MAX_DEPTH });
if (node.kind === "and" || node.kind === "or") {
const joined = node.children.map(c => walk(c, depth + 1)).join(node.kind === "and" ? " AND " : " OR ");
return `(${joined})`;
}
if (++terms > MAX_TERMS) throw new FilterError("/filter", "too_many_terms", { max: MAX_TERMS });
const field = Object.prototype.hasOwnProperty.call(fields, node.field) ? fields[node.field] : undefined;
if (!field) throw new FilterError(`/filter/${node.field}`, "unknown_field"); // allowlist
if (!field.operators.includes(node.op)) {
throw new FilterError(`/filter/${node.field}`, "operator_not_allowed", { allowed: field.operators });
}
cost += field.cost ?? 1;
if (cost > COST_BUDGET) throw new FilterError("/filter", "query_too_expensive", { budget: COST_BUDGET });
const coerced = coerce(node.value, field); // throws on type mismatch
if (node.op === "=in=") {
const list = (coerced as unknown[]).slice(0, 100); // bound IN list size
const placeholders = list.map(v => `$${params.push(v)}`).join(", ");
return `${field.column} IN (${placeholders})`; // column from the allowlist ONLY
}
return `${field.column} ${SQL_OP[node.op]} $${params.push(coerced)}`;
}
return { where: walk(ast, 0), params };
}
The column expression comes from the allowlist and the value from a bound parameter. At no point is user text concatenated into SQL — the field name is a lookup key, not a string that reaches the database.
Step 3 — Coerce values by declared type
# coerce.py — type coercion is a security control, not a convenience
from datetime import datetime
from uuid import UUID
def coerce(raw: str, field: dict):
t = field["type"]
try:
if t == "integer":
value = int(raw)
if not -2**63 <= value < 2**63:
raise ValueError("out of range")
return value
if t == "money":
value = int(raw) # minor units only; never float
if value < 0:
raise ValueError("negative")
return value
if t == "uuid":
return str(UUID(raw)) # rejects anything not a UUID
if t == "timestamp":
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
if t == "enum":
if raw not in field["enumValues"]:
raise ValueError("not an allowed value")
return raw
if t == "string":
if len(raw) > 200:
raise ValueError("too long")
return raw
except (ValueError, TypeError) as exc:
raise FilterError(field["column"], "invalid_value") from exc
Coercion closes the type-confusion class of bug, where a string where an integer is expected reaches a query planner that coerces it differently, or where an oversized integer overflows in a backend that does not check. It also short-circuits a surprising amount of probing: an attacker cannot test operators against a UUID column if nothing but a UUID parses.
Step 4 — Return errors that reveal nothing
{
"type": "urn:api:errors:v1:validation-failed",
"title": "Filter expression is not valid",
"status": 422,
"violations": [
{ "pointer": "/filter", "code": "unknown_field", "message": "Field 'internal_notes' cannot be filtered.",
"params": { "allowed": ["status", "currency", "total_minor", "created_at", "customer_id"] } }
]
}
Listing the allowed fields is safe and helpful — they are already documented — while echoing the raw expression or a database error message is neither. A driver-level error string discloses table names, column types and sometimes fragments of other queries, which turns a rejected filter into a schema dump. The violation shape follows Validation Error Payloads.
Standard compliance
| Concern | Reference | Note |
|---|---|---|
| Query parameter syntax | RFC 3986 §3.4 | Percent-decode before parsing, once |
422 for an invalid filter |
RFC 9110 §15.5.21 | The expression parsed but broke a rule |
400 for an unparseable filter |
RFC 9110 §15.5.1 | Syntax error in the expression itself |
| Violation reporting | RFC 9457 §3.2 | Extension members carrying the offending position |
| Parameter length bound | OpenAPI 3.1 §4.8.12 | maxLength on the parameter schema |
One subtlety on the first row: decode percent-encoding exactly once, at the framework boundary. Double-decoding is its own vulnerability class, because %2527 becomes %27 on the first pass and ' on the second — slipping a quote past a validator that ran in between.
The allowlist is also a data-exposure control, not only an injection control:
Cost, denial of service and safety
An allowlisted filter can still be ruinous. LIKE '%term%' on a hundred-million-row table is a full scan, and a caller may issue it a hundred times a second. Assign each operator a cost — equality on an indexed column is 1, a range scan is 3, a leading-wildcard match is 20 — sum the parsed expression, and reject above a budget. Back it with a statement timeout so anything that slips through cannot run indefinitely, and with the per-endpoint quotas from Rate Limiting & Quota Contracts. The three controls fail independently, which is the point.
SDK / codegen downstream effect
// Generated client: the filter is a string, so the SDK cannot validate it
- await client.invoices.listInvoices({ filter: userSuppliedString });
// A typed builder in the wrapper layer makes invalid filters unrepresentable
+ await client.invoices.listInvoices({
+ filter: f.and(
+ f.eq("status", "paid"), // field names are a union type
+ f.gt("total_minor", 1000), // value type checked per field
+ ).toString(),
+ });
Deriving the builder’s field union from the documented allowlist means a client cannot even express a filter on internal_notes — the mistake becomes a compile error instead of a 422. The wrapper-layer pattern is the same one used for retries and pagination in Client SDK Generation Pipelines.
Common mistakes
| Mistake | Correct approach |
|---|---|
| Concatenating filter values into SQL | Parse to a tree; bind values as parameters |
| Using the client’s field name as a column name | Look the column up in an allowlist |
| Escaping input instead of allowlisting | Allowlist fields, operators and types |
| No depth or term limits | Cap depth, term count and IN list size |
| Unbounded expensive operators | Assign costs and enforce a budget |
| Returning the database error message | Return a structured violation with no internals |
| Double-decoding percent-encoded input | Decode exactly once, at the boundary |
| Filterable fields not documented | Document them; the allowlist is part of the contract |
FAQ
Do parameterised queries alone prevent filter injection?
They prevent value injection, which is the most famous half of the problem, and they do nothing for the other half. Field names, sort columns, table aliases and operators cannot be bound as parameters — SQL simply does not allow an identifier placeholder — so any design that takes those from user input is concatenating strings no matter how careful it is about values. The fix is structural: the API field name is a key into an allowlist that yields a column expression you wrote, and anything not in that map is rejected before a query is built.
Is escaping user input a sufficient defence?
No, and reaching for it is usually a sign the design took a wrong turn earlier. Escaping is a denylist wearing a disguise: it requires you to know every metacharacter for every backend you might target, to apply it in the right order relative to decoding, and to keep that knowledge correct as backends change. One missed case is a vulnerability. Parsing into a typed tree and validating against an allowlist inverts the burden — the parser accepts only the grammar you defined, and every field, operator and value type is explicitly permitted rather than merely not-yet-forbidden.
How do I stop an expensive but legitimate filter from taking down the database?
Price it. Assign each operator a cost reflecting its likely plan — indexed equality 1, range 3, IN with a large list 5, leading-wildcard match 20 — sum the costs of the parsed expression, and reject anything above a budget with a clear 422. Add a statement timeout at the database connection so a query that slips through has a hard ceiling, and a per-endpoint rate limit so a caller cannot issue expensive-but-legal queries continuously. Each control catches what the others miss, and none of them requires guessing which queries an attacker will send.
Related
- Advanced Filtering Operators — up-link: the section defining the filter grammar this parser accepts
- Query Patterns & Data Shaping Strategies — up-link: the parent reference for query contracts
- RSQL and FIQL Filter Operator Syntax — the expression syntax being parsed
- Handling Invalid Sort Field Errors — the same allowlist problem for ordering
- Validation Error Payloads — the error contract these rejections use