Skip to content

fix(parser)!: limit filter length and nesting depth by default - #135

Merged
pdevito3 merged 2 commits into
v2from
fm/qk-breaking-prs-2
Oct 2, 2026
Merged

pdevito3 merged 2 commits into
v2from
fm/qk-breaking-prs-2

Conversation

@pdevito3

@pdevito3 pdevito3 commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

For later consideration in a major version. Do not merge now. #134 turned these limits off by default to keep v1.x compatible with v1.14.2 (restore commit 42866b9). This PR turns them on by default again. It re-applies the default part of de350f7 (#109). The captain decides on this PR separately.

Summary

 QueryKitSettings
-  public const int DefaultMaxNestingDepth = int.MaxValue;
-  public const int DefaultMaxInputLength = int.MaxValue;
+  public const int DefaultMaxNestingDepth = 32;
+  public const int DefaultMaxInputLength = 5000;

MaxInputLength and MaxNestingDepth stay as settings. An app can raise each limit, lower it, or set it to int.MaxValue to turn it off.

v1.14.2 behavior (and main)

The parser accepts a filter of any length and any nesting depth.

New behavior

  • A filter longer than 5000 characters throws QueryKitInputLengthExceededException.
  • A filter with more than 32 levels of parentheses throws QueryKitNestingDepthExceededException.
  • The length check runs before the parse. The depth check runs in the parse, when the parser enters the group that goes over the limit.
  • Both exceptions derive from QueryKitException. An app that maps QueryKitException to HTTP 400 returns 400.
  • The nesting-depth check counts each parenthesized group. A ( inside a quoted value does not count.
  • Sort strings have no limit.

Example

((((((((((((((((((((((((((((((((( Age > 1 )))))))))))))))))))))))))))))))))     (33 levels)
  • v1.14.2 and main: x => (x.Age > 1).
  • This PR: QueryKitNestingDepthExceededException, "depth of 33 ... maximum allowed depth of 32".

Another input that v1.14.2 accepts and this PR rejects: Id ^^ [...] with 140 GUIDs (5606 characters).

Security risk on v1.14.2

  • A filter with a few thousand nested parentheses overflows the call stack. At depth 5000 and 10000 the process stopped with Stack overflow. and exit code 134. A catch block cannot stop a stack overflow in .NET.
  • A filter of about 10 KB in one HTTP request is enough. It crashes the host process of every app that sends user input to QueryKit.
  • Parse time grows faster than the square of the depth. Depth 500 took 4076 ms and depth 1000 took 21771 ms of CPU. Depth 2500 did not finish in 150 s.
  • With the default limits, the same input throws a QueryKitException, and the process keeps running.

Justification

Most apps pass a filter string from a client to QueryKit without a length check. With no default limit, each of these apps has a denial of service hole that one request can use. A safe default protects the apps that do not know about the risk. An app with long in-lists or deep generated filters can raise the limits in one setting.

Migration

To keep the v1.14.2 behavior, turn the limits off:

var config = new QueryKitConfiguration(config =>
{
    config.MaxInputLength = int.MaxValue;
    config.MaxNestingDepth = int.MaxValue;
});

README

The Parse Limits section gives the 5000 and 32 defaults again. It also tells how to turn a limit off.

Tests

These tests come back from main before #134:

  • ParseLimitsTests.filter_over_33_nesting_levels_parses_by_default -> filter_over_default_nesting_depth_throws
  • ParseLimitsTests.filter_over_5000_characters_parses_by_default -> filter_over_default_input_length_throws
  • ParseLimitsTests.configuration_that_implements_only_the_interface_has_no_limits -> configuration_that_implements_only_the_interface_uses_the_default_limits

quoted_value_with_33_parentheses_parses_by_default stays, because a ( inside a quoted value does not count on main.

deep_filter_with_quoted_close_parentheses_throws_instead_of_overflowing_the_stack now sets MaxInputLength = int.MaxValue. Its input is longer than 5000 characters, and the test is about the depth limit.

dotnet test: 470 unit tests and 297 Postgres integration tests (Testcontainers) pass, 0 failures.

Rebase on main

This branch is rebased on current main. Main now counts the nesting depth in the grammar (#157). This PR changes only the two default values, their doc comments, and the README text for the defaults.

v1.14.2 parsed a filter of any length and any nesting depth. A filter
with a few thousand nested parentheses overflowed the call stack and
stopped the host process. A catch block cannot stop a stack overflow.
At depth 1000, one parse took more than 20 seconds of CPU.

DefaultMaxInputLength is 5000 again and DefaultMaxNestingDepth is 32
again. MaxInputLength and MaxNestingDepth stay as settings, so an app
can raise or lower each limit.

BREAKING CHANGE: a filter longer than 5000 characters throws
QueryKitInputLengthExceededException, and a filter with more than 32
levels of parentheses throws QueryKitNestingDepthExceededException.
v1.14.2 parsed both. To keep the old behavior, set MaxInputLength and
MaxNestingDepth to int.MaxValue.
@pdevito3
pdevito3 force-pushed the fm/qk-breaking-prs-2 branch from eed243a to d2bb575 Compare October 2, 2026 19:54
@pdevito3
pdevito3 changed the base branch from main to v2 October 2, 2026 19:54
@pdevito3
pdevito3 merged commit 0f82f3d into v2 Oct 2, 2026
2 checks passed
@pdevito3
pdevito3 deleted the fm/qk-breaking-prs-2 branch October 2, 2026 19:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant