-
Notifications
You must be signed in to change notification settings - Fork 0
Security
QueryForge accepts a filter description from a client and turns it into a database query. That is inherently a place where care is required, so this page states plainly what is protected automatically, what you must configure, and what is outside the library's reach.
The assumed attacker is a caller who can post an arbitrary Query body to an endpoint you have
exposed. They can choose any column name, any operator, any value, any page size, and any grouping.
They cannot choose the target object — that is not in the Query model at all.
The concerns that follow from this:
| Concern | Handled by |
|---|---|
| SQL injection via values | parameterization — automatic |
| SQL injection via column names | the column whitelist — automatic |
| Schema discovery by probing column names | the column whitelist — automatic |
| Reading a column that exists but should be private | validation — you configure it |
| Choosing which table is queried | not expressible in the model — structural |
| Data-dump via a huge page size | validation — you configure it |
| Expensive queries as a denial of service | partly your responsibility — see below |
| Cross-tenant reads | your responsibility — compose the restriction server-side |
These are properties of the implementation. They cannot be switched off and require no configuration.
Every caller-supplied value becomes a DbParameter. No value is ever concatenated into SQL text.
SELECT * FROM [dbo].[Users] WHERE ([Country] = @p0 AND [Score] > @p1)
ORDER BY [Score] DESC OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLYA value containing '; DROP TABLE Users; -- is bound as that literal string and matches nothing. The
same holds for the EF Core provider, which injects values through a closure so EF emits parameters
rather than inlined constants.
This also buys plan reuse — the statement text is stable across calls that differ only in values.
A column name is not escaped and emitted — it is looked up, and dropped if it is not found.
| Provider | The whitelist is | How it is obtained |
|---|---|---|
| Dapper | the target's real result-set columns |
SELECT * FROM target WHERE 1 = 0, then read the reader's field names and types |
| EF Core | the entity's readable instance properties | reflection, cached per type |
| In-Memory | the model's readable instance properties | reflection, cached per type |
This is the schema-probing defence. A caller filtering on "Password" against a table with no such
column gets that condition silently dropped — the same response as a caller who filtered on nothing.
There is no error, no timing difference worth measuring, and nothing in the response that
distinguishes "no such column" from "no matching rows".
Reading the shape from the object's own result set rather than from a catalog view means it works identically on every engine and is correct for views and functions whose columns are computed.
Even a whitelisted name is quoted by the dialect before it reaches SQL, and each dialect neutralizes its own closing delimiter:
| Engine | Quoting | Escapes |
|---|---|---|
| SQL Server | [name] |
] → ]]
|
| PostgreSQL, Oracle, SQLite | "name" |
" → ""
|
| MySQL | `name` |
` → ``
|
Only the wildcards QueryForge adds are significant. A caller searching for % finds the literal
character, not every row. %, _ and the escape character are escaped on every engine; SQL Server
additionally escapes [. The matching ESCAPE clause is always emitted.
This matters for more than correctness: a leading-wildcard search over a large table is a cheap way to force a scan, and letting callers inject their own wildcards makes that worse.
Query has no object, table, or entity field. The target comes from:
-
DapperQuery.Object, which you set withForObject(...)on the server; - the
IQueryable<T>you call the EF Core extension on; - the
IEnumerable<T>you call the in-memory extension on.
The recommended pattern makes this structural rather than conventional — bind the body as Query,
never as DapperQuery:
app.MapPost("/api/users/query", async (Query clientQuery, IDapperQueryService svc) =>
{
var dq = DapperQueryBuilder
.FromBase(clientQuery) // only filters/paging/sorting/grouping
.ForObject("Users", "dbo", DapperObjectType.Table) // the target — from your code
.Build();
return await svc.QueryAsync<User>(dq);
});Do not model-bind a
DapperQueryfrom a request body. It would let a caller name any object the connection's login can read. If you have a genuine need for a client-selected target, map an allow-listed key to a name in your own code:var target = key switch { "users" => ("Users", "dbo"), "orders" => ("Orders", "dbo"), _ => throw new ArgumentException("Unknown dataset") };
No stored procedures are created, no DDL is executed, and no schema permissions are required. The
database login QueryForge runs under needs only SELECT on the objects you point it at.
The automatic protections stop a caller reaching something that does not exist. They do not stop a caller reaching something that exists and is sensitive. That is Validation, and it is yours to write.
The rule sets are independent by design, so a column denied only in Select can still be filtered on
— and a caller can extract a value one comparison at a time.
query.Validate(rules =>
{
rules.Select(c => c.Deny("PasswordHash", "SecurityStamp"));
rules.Where(c => c.Deny("PasswordHash", "SecurityStamp"));
rules.Sort(c => c.Deny("PasswordHash", "SecurityStamp"));
rules.GroupBy(c => c.Deny("PasswordHash", "SecurityStamp"));
}, QueryValidationMode.SilentStrip);Sorting and grouping leak too. Sorting by a denied column reveals its ordering; grouping by it reveals its distinct values and their counts. Deny all four.
A deny-list has to be updated every time a column is added to the table. An allow-list fails closed.
rules.Select(c => c.Allow("UserId", "FirstName", "LastName", "Country", "Score"));
rules.Where(c => c.Allow("Country", "Department", "Score", "IsActive", "CreatedOn"));rules.PageSize(p => p.Max(100));Without a cap, "paging": { "size": 1000000 } is a one-request data dump.
Never rely on the client sending a tenant filter — compose it into the query before QueryForge sees
it. With EF Core that is an ordinary Where, or a global query filter:
await db.Users
.Where(u => u.TenantId == currentTenant) // survives everything the client sends
.AsNoTracking()
.ToQueryResultAsync<User>(query);With Dapper, the target itself is the boundary — point at a view or table-valued function that is already scoped:
.ForObject("tvf_UsersForTenant", "dbo", DapperObjectType.TVF,
new Dictionary<string, object?> { ["TenantId"] = currentTenant })A criteria-based tenant filter is not equivalent, because SilentStrip may strip it and an
Or-joined group can widen it.
QueryForge bounds some of this and not all of it.
Bounded automatically:
- Parameter count. Values scale with the number of conditions the caller wrote; the grouping
INlist is bounded by page size. - Statement count. One statement for a flat query, three for a grouped one — never per-row.
- Schema discovery. Cached per object for the process lifetime, so it is one extra round trip per target ever, not per request.
Not bounded automatically — your responsibility:
| Risk | Mitigation |
|---|---|
| Huge page size | rules.PageSize(p => p.Max(n)) |
| Grouping a large table by a low-cardinality column, returning every row | filter first; allow-list groupable columns; cap page size |
| Deeply nested criteria with hundreds of conditions | cap request body size; reject absurd condition counts before validating |
Leading-wildcard Contains forcing a scan |
allow-list which columns accept text operators; consider full-text search for real search boxes |
| Sorting or filtering an unindexed column | allow-list sortable columns to the ones you have indexed |
| A single slow query holding a connection | pass commandTimeout on the Dapper call |
await svc.QueryAsync<User>(dapperQuery, commandTimeout: 30);-
Give the database login
SELECTonly, on only the objects you expose. QueryForge never needs more, and that turns a mistake elsewhere in your stack into a read rather than a write. - Point endpoints at views, not base tables, when the base table has columns you never want reachable. A column that is not in the view is not in the whitelist, so it cannot be named at all — a stronger guarantee than denying it in policy.
-
Log the query, not just the SQL. The
Queryobject is the caller's intent and is the useful artefact when investigating abuse. It serializes cleanly. -
Watch for repeated stripped conditions. A caller whose conditions are constantly being dropped
is probing. The library does not surface this — if you want it, run validation in
ThrowExceptionmode on a copy of the query and log the result while still serving the request inSilentStripmode.
Open a security advisory on the GitHub repository rather than a public issue.
QueryForge · part of the PepperX Ecosystem · MIT licensed · packages 2.0.0, .NET 10
Foundations
- Getting Started
- Architecture
- Query Model
- Query Semantics
- Results and Metadata
- JSON Contract
- Fluent Builders
Behaviour
Providers
- Dapper Provider
- Dapper: Generated SQL
- Dapper: Dialects
- EF Core Provider
- EF Core: Joins & Includes
- In-Memory Provider
Practice
Reference