Skip to content

Security

AmirHosseinMp02 edited this page Aug 1, 2026 · 1 revision

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.


Threat model

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

Automatic protections

These are properties of the implementation. They cannot be switched off and require no configuration.

1. Values are always parameters

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 ONLY

A 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.

2. Column names are checked against what the target really exposes

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.

3. Identifiers are delimited as a last line of defence

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` ```

4. LIKE wildcards in a value are escaped

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.

5. The client cannot choose the target

Query has no object, table, or entity field. The target comes from:

  • DapperQuery.Object, which you set with ForObject(...) 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 DapperQuery from 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")
};

6. Nothing is deployed to your database

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.


What you must configure

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.

Seal off sensitive columns in all four places

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.

Prefer allow-lists on public endpoints

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"));

Always cap page size

rules.PageSize(p => p.Max(100));

Without a cap, "paging": { "size": 1000000 } is a one-request data dump.

Isolate tenants server-side, not by policy

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.


Denial of service

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 IN list 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);

Operational advice

  • Give the database login SELECT only, 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 Query object 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 ThrowException mode on a copy of the query and log the result while still serving the request in SilentStrip mode.

Reporting a vulnerability

Open a security advisory on the GitHub repository rather than a public issue.

Clone this wiki locally