-
Notifications
You must be signed in to change notification settings - Fork 0
Validation
A Query that arrived from outside your API describes what a caller wants. Validation is where you
state what a caller is allowed to want. It is a policy layer you configure per endpoint, and it is
independent of the structural protections QueryForge applies automatically (see
Security).
public enum QueryValidationMode { SilentStrip, ThrowException }| Mode | Behaviour | Use for |
|---|---|---|
SilentStrip |
Removes what is not allowed, clamps what is out of range, and lets the request succeed. | Public APIs. A client that asks for one column too many still gets its page. |
ThrowException |
Collects every violation and throws QueryValidationException. |
Internal APIs and strict environments, where a bad request should fail loudly. |
SilentStrip is the default parameter value.
Both modes run every rule before returning — ThrowException does not stop at the first violation, so
InvalidProperties lists all of them.
query.Validate(rules =>
{
rules.PageSize(p => p.Max(100).Min(1));
rules.Select(c => c.Deny("PasswordHash", "SecretKey"));
rules.Where(c => c.Deny("PasswordHash", "SecretKey"));
rules.Sort(c => c.Allow("Score", "CreatedOn", "LastName"));
rules.GroupBy(c => c.Allow("Country", "Department", "Role"));
}, QueryValidationMode.SilentStrip);Validate returns the same Query instance so it can be chained, and mutates it in place in
SilentStrip mode. That is why Query is a mutable class rather than a record.
There is also an overload taking a pre-built QueryValidationRules, which is how you share one policy
across endpoints:
static readonly QueryValidationRules PublicPolicy = new QueryValidationRules()
.PageSize(p => p.Max(100))
.Select(c => c.Deny("PasswordHash"))
.Where(c => c.Deny("PasswordHash"));
query.Validate(PublicPolicy, QueryValidationMode.SilentStrip);QueryValidationRules holds no per-request state, so a single static instance is safe to share across
requests and threads.
public class QueryValidationRules
{
public QueryValidationRules PageSize(Action<QueryPagingRuleBuilder> configure);
public QueryValidationRules Select(Action<QueryColumnRuleBuilder> configure); // SelectColumns
public QueryValidationRules Sort(Action<QueryColumnRuleBuilder> configure); // SortColumns
public QueryValidationRules GroupBy(Action<QueryColumnRuleBuilder> configure); // GroupByColumns
public QueryValidationRules Where(Action<QueryColumnRuleBuilder> configure); // Criteria conditions
}
public class QueryColumnRuleBuilder
{
public QueryColumnRuleBuilder Allow(params string[] columns);
public QueryColumnRuleBuilder Deny(params string[] columns);
}
public class QueryPagingRuleBuilder
{
public QueryPagingRuleBuilder Max(int max);
public QueryPagingRuleBuilder Min(int min);
}The five column rule sets are independent. Denying a column in Select does not deny it in
Where — a caller could still filter on it and infer its values. To seal a column off completely,
deny it in all four places:
rules.Select(c => c.Deny("PasswordHash"));
rules.Where(c => c.Deny("PasswordHash"));
rules.Sort(c => c.Deny("PasswordHash"));
rules.GroupBy(c => c.Deny("PasswordHash"));This separation is deliberate — it is what lets you allow filtering on a column you never return, and vice versa — but it does mean a "hide this column" policy has to be written out four times.
| Configuration | Effect |
|---|---|
| neither | everything is allowed |
Allow(…) only |
only those columns are allowed |
Deny(…) only |
everything except those |
| both | must be in Allow and not in Deny — deny wins |
Both lists are case-insensitive.
An empty allow-list means "no allow-list", not "allow nothing". If you want to reject everything, deny what exists, or simply do not expose the endpoint.
| Rule | SilentStrip |
ThrowException |
|---|---|---|
Max(n) and Size > n
|
Size is clamped to n
|
error "Paging.Size > n"
|
Min(n) and Size < n
|
Size is raised to n
|
error "Paging.Size < n"
|
Min is evaluated against the value Max may already have clamped. Paging.Number is not
validated — a page past the end is harmless and returns an empty page.
Note that a Size of 0 or less never reaches the provider as-is: it is normalized to the default of
12 at execution time. If you set Min(1), a zero size is raised to 1 by validation instead.
| Target | Behaviour when a column is not allowed |
|---|---|
SelectColumns |
the entry is removed |
SortColumns |
the descriptor is removed |
GroupByColumns |
the descriptor is removed |
Criteria conditions |
the condition is removed from its group |
| a group left with zero conditions | the whole group is dropped |
Paging.Size |
clamped into range |
Because the collections are replaced rather than edited, the original QueryCriteria instance a
caller may still hold is not mutated — the query's Criteria property is pointed at a new record.
Stripping every condition from every group leaves an empty criteria, which matches everything. If your policy is "this endpoint requires a tenant filter", enforce it by composing the restriction server-side rather than by relying on the client to send it. See Recipes.
public class QueryValidationException : Exception
{
public IReadOnlyList<string> InvalidProperties { get; }
}- Message:
"Query validation failed. See InvalidProperties for details." -
InvalidPropertiescontains one entry per violation:- the column name for a select, sort, group-by or condition violation;
-
"Paging.Size > 100"/"Paging.Size < 5"for a paging violation; -
"Object.Name is required.","Object.Schema 'x' is not allowed."or"Object.Name 'x' is not allowed."for a Dapper object violation.
- A column violating two rules appears twice — once per rule set.
Turning that into a 400:
try
{
query.Validate(Policy, QueryValidationMode.ThrowException);
return Results.Ok(await svc.QueryAsync<User>(dq));
}
catch (QueryValidationException ex)
{
return Results.ValidationProblem(
ex.InvalidProperties
.Distinct()
.ToDictionary(p => p, p => new[] { "Denied by security policy" }));
}
InvalidPropertiesnames the columns a caller asked for. If your threat model treats the existence of a column as sensitive, return a generic message rather than echoing the list.
The Dapper provider extends the rule set with the target object. This is a separate extension method
on DapperQuery, and it runs the base rules too — call it instead of, not as well as, the base one.
using PepperX.QueryForge.Dapper;
dapperQuery.Validate(rules =>
{
rules.Object(o => o
.AllowSchema("dbo", "reporting")
.DenySchema("sys")
.AllowTable("Users", "Orders", "vw_ActiveUsers")
.DenyTable("AuditLog")
.RequireName());
rules.PageSize(p => p.Max(100));
rules.Select(c => c.Deny("PasswordHash"));
}, QueryValidationMode.SilentStrip);public class DapperObjectRuleBuilder
{
public DapperObjectRuleBuilder AllowSchema(params string[] schemas);
public DapperObjectRuleBuilder DenySchema(params string[] schemas);
public DapperObjectRuleBuilder AllowTable(params string[] tables);
public DapperObjectRuleBuilder DenyTable(params string[] tables);
public DapperObjectRuleBuilder RequireName(bool require = true); // default: true
}| Situation | SilentStrip |
ThrowException |
|---|---|---|
Object is null or Name is blank, and RequireName is on |
nothing — it cannot be repaired | error "Object.Name is required."
|
Schema not allowed / denied |
Schema is reset to "", falling back to the dialect default |
error "Object.Schema 'x' is not allowed."
|
Name not allowed / denied |
nothing — removing the name would break the query | error "Object.Name 'x' is not allowed."
|
Two of those three cannot be silently repaired, which is worth internalizing:
SilentStripcannot enforce an object policy. A denied table name stays, and a missing name stays missing — the query then fails at execution with anInvalidOperationExceptioninstead. If you are validating the target object at all, useThrowException.
In practice the object rules matter only when the target is derived from input. If you set
ForObject(...) from a constant in your own code — the recommended pattern — the target is already
under your control and these rules are belt-and-braces.
client JSON ──► Query ──► Validate() ──► provider ──► whitelist check ──► SQL / expression tree
▲ ▲
your policy: what a structural: what the
caller may ask for target actually exposes
The two are complementary and neither replaces the other:
- The whitelist is automatic and cannot be switched off. It stops a caller naming a column that does not exist — the schema-probing defence.
- Validation is yours to configure. It stops a caller naming a column that does exist but that they should not reach.
A column you never deny is reachable by any caller who knows its name, as long as it is really on the target. That is the correct default for an internal API and the wrong one for a public endpoint — which is why validation exists.
static readonly QueryValidationRules Public = new QueryValidationRules()
.PageSize(p => p.Max(100))
.Select(c => c.Allow("UserId", "FirstName", "LastName", "Country", "Score"))
.Where(c => c.Allow("Country", "Department", "Score", "IsActive", "CreatedOn"))
.Sort(c => c.Allow("LastName", "Score", "CreatedOn"))
.GroupBy(c => c.Allow("Country", "Department"));Allow-lists rather than deny-lists, so a column added to the table later is not automatically exposed. This is the safer default for anything client-facing.
static readonly QueryValidationRules Internal = new QueryValidationRules()
.PageSize(p => p.Max(5000))
.Select(c => c.Deny("PasswordHash", "SecurityStamp"))
.Where(c => c.Deny("PasswordHash", "SecurityStamp"));Deny-lists, larger pages, and ThrowException so a mistake in an internal caller is loud.
var rules = user.IsInRole("Admin") ? Internal : Public;
query.Validate(rules, user.IsInRole("Admin")
? QueryValidationMode.ThrowException
: QueryValidationMode.SilentStrip);Validation is a pure function over a Query, so it needs no database:
[Fact]
public void Denied_column_is_stripped()
{
var query = QueryBuilder.Select("UserId", "PasswordHash").Build();
query.Validate(r => r.Select(c => c.Deny("PasswordHash")), QueryValidationMode.SilentStrip);
Assert.Equal(["UserId"], query.SelectColumns);
}
[Fact]
public void Denied_column_throws_and_is_reported()
{
var query = QueryBuilder.Select("UserId", "PasswordHash").Build();
var ex = Assert.Throws<QueryValidationException>(() =>
query.Validate(r => r.Select(c => c.Deny("PasswordHash")), QueryValidationMode.ThrowException));
Assert.Contains("PasswordHash", ex.InvalidProperties);
}The repository's own suites cover both modes across all five rule sets; see Testing.
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