Skip to content

fix(filter)!: apply PreventFilter and PreventSort to every property path - #136

Open
pdevito3 wants to merge 1 commit into
mainfrom
fm/qk-breaking-prevent-bypass
Open

pdevito3 wants to merge 1 commit into
mainfrom
fm/qk-breaking-prevent-bypass

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 opened these bypasses again to keep v1.x compatible with v1.14.2 (restore commit 2f33f60). This PR closes them again. It re-applies the permission parts of 988fdff, 2781423, 630d080, 1f40773, and b350bf8 (#113). The captain decides on this PR separately.

Summary

  • The parser resolves each property reference with PropertyResolver and checks PreventFilter in arithmetic, on the right side, in a property list, and on the left side. The lookup ignores the letter case.
  • A derived property and a custom operation with PreventFilter are checked too.
  • SortParser checks PreventSort on the resolved member, so another letter case or the member name of a property with a query name is also checked. A derived property with PreventSort is checked too.
  • A prevented clause follows IgnoredClauseBehavior (true == true by default, or removed). A prevented sort is skipped.
  • A property list uses the case setting of the resolved member path, so a per-property case setting also applies when the caller writes the name in another case.

Scope: arithmetic in this PR checks only the prevent settings. It does not apply MaxPropertyDepth. That is item P, in its own PR.

v1.14.2 behavior (and main)

PreventFilter is checked only for a left-side member, by its name in the exact case after the query-name rewrite. PreventSort is checked by the typed path in the exact case. A caller can filter or sort by a prevented property in six ways:

Case Input Configuration v1.14.2 and main This PR
I1 (Age + 0) > 30 Age PreventFilter x => ((x.Age + 0) > 30), rows [Bob,Cid] clause ignored, all rows
I2 FirstName == Secret Secret PreventFilter x => (x.FirstName == x.Secret) clause ignored
I3 (secret, FirstName) @=* "s" Secret PreventFilter filters on Secret only FirstName
I4 secret == "s" Secret PreventFilter, query name hidden x => (x.Secret == "s"), rows [Ann] clause ignored
I5 fullName == "Ann Lee" derived property with PreventFilter filters on the derived value clause ignored
I6 sort age desc Age PreventSort, query name years sorted [Cid,Bob,Ann] sort skipped, [Ann,Bob,Cid]

A custom operation with PreventFilter is also still applied on v1.14.2 and main.

New behavior

Each path in the table above respects PreventFilter and PreventSort. Filters and sorts that do not use a prevented property do not change.

Example

var config = new QueryKitConfiguration(c => c.Property<Person>(x => x.Salary).PreventFilter());
var people = dbContext.People.ApplyQueryKitFilter("(Salary + 0) > 50000", config);
  • v1.14.2 and main: WHERE p."Salary" + 0 > 50000. The caller sees which rows earn more than 50000.
  • This PR: the clause is true == true (or removed with IgnoredClauseBehavior.Remove). Every row comes back, so the filter shows nothing about Salary.

Security risk on v1.14.2

PreventFilter and PreventSort exist to stop a caller from querying a field. With any of the six bypasses, a caller can find the value of a hidden field one comparison at a time, for example (Salary + 0) > 50000, then > 75000, and so on. A sort bypass shows the order of the hidden values. Every app that relies on these settings to hide a field from API callers is exposed.

Justification

The settings promise that a caller cannot filter or sort by the property. A check that covers only one syntax form does not keep that promise. This PR applies the settings to every path that reaches the property. The change breaks only callers that used a bypass. They now get the rows that the configuration permits.

Migration

No setting brings back the old behavior. If a caller needs to filter by a property, remove PreventFilter from it.

README

The Property Settings sections for filters and sorts now tell where PreventFilter and PreventSort apply. The PreventSort bullet also said "prevent filtering". It says "prevent sorting" now.

Tests

These tests come back from main before #134.

Unit, PropertyResolverTests:

  • prevented_property_in_arithmetic_is_true_equals_true (back)
  • prevented_property_in_arithmetic_is_still_filtered -> prevented_property_in_arithmetic_removes_the_clause
  • prevented_property_on_the_right_side_of_arithmetic_is_true_equals_true (back)
  • prevented_property_on_the_right_side_of_arithmetic_is_still_filtered -> prevented_property_on_the_right_side_of_arithmetic_removes_the_clause
  • prevented_property_on_the_right_side_is_true_equals_true_when_replaced (back)
  • prevented_property_on_the_right_side_is_still_compared -> prevented_property_on_the_right_side_removes_the_clause
  • prevented_property_on_the_right_side_removes_the_clause_in_any_case (back)
  • prevented_property_in_a_list_in_another_case_is_still_filtered -> prevented_property_in_a_list_is_skipped_in_any_case
  • prevented_property_with_a_query_name_is_still_filtered_by_its_member_name_in_another_case -> prevented_property_with_a_query_name_removes_the_clause_when_written_by_its_member_name_in_any_case
  • prevented_sort_property_with_a_query_name_still_sorts_by_its_member_name_in_another_case -> prevented_sort_property_with_a_query_name_is_skipped_when_written_by_its_member_name
  • prevented_derived_property_is_still_filtered -> prevented_derived_property_removes_the_clause
  • prevented_derived_property_in_a_list_is_still_filtered -> prevented_derived_property_in_a_list_is_skipped
  • prevented_custom_operation_is_still_applied -> prevented_custom_operation_removes_the_clause
  • prevented_custom_operation_is_true_equals_true_when_replaced (back)
  • prevented_derived_sort_property_still_sorts -> prevented_derived_sort_property_is_skipped

Integration, PropertyResolverTests (Postgres), all back:

  • prevented_property_in_arithmetic_is_not_filtered
  • prevented_property_on_the_right_side_is_not_compared
  • prevented_property_in_a_list_is_not_filtered_in_any_case
  • prevented_sort_property_with_a_query_name_is_not_sorted_when_written_by_its_member_name
  • prevented_custom_operation_is_not_filtered
  • prevented_derived_property_is_not_filtered
  • prevented_derived_sort_property_is_not_sorted

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

Rebase on main

This branch is rebased on current main. This PR replaces the last call of the left-side helper GetFilterPropertyInfo, so this PR deletes that helper. The main tests for a query name that matches another property path pass without a change.

Interaction with #150 (item L)

#150 accepts a property path on the right side. After this PR and #150 are both merged, bring back 2 unit tests from main before #134 (PropertyResolverTests): property_path_on_the_right_side_obeys_max_property_depth and prevented_property_path_on_the_right_side_removes_the_clause. Each test needs both changes. With both rebased branches merged locally and the 2 tests added, 476 unit tests pass.

Interaction with #151 (item O)

#151 (item O) adds the same ResolveWithoutDepthCheck split and also edits the arithmetic Select. If both merge, the second one gets a text conflict. The combined code is the ResolveArithmeticProperties of main before #134, with the CanFilter check and the unknown-property check.

Interaction with #154 (items J5 and J-bis)

#154 maps a query name in PropertyResolver.Resolve and in arithmetic. It also changes the property-list check to find the setting by the query name first. If both merge, the second one gets text conflicts in Resolve, in the property-list check, and in the tests. In the combined Resolve, the depth check uses the mapped path, and ResolveWithoutDepthCheck maps the query name too. Then the arithmetic CanFilter check of this PR also applies to a query name, for example (stars + 0) > 3 with PreventFilter() on Rating.

PreventFilter was checked only for a left-side member, by its name in
the exact case after the query-name rewrite. Arithmetic, the right side
of a comparison, a property list in another case, derived properties,
and custom operations skipped the check. PreventSort was checked by the
typed path in the exact case. A caller could learn the value of a
hidden field one comparison at a time.

The parser now resolves each property reference and applies the
prevent settings in each of these places. A prevented clause follows
IgnoredClauseBehavior, and a prevented sort is skipped. Arithmetic
does not apply MaxPropertyDepth in this change.

BREAKING CHANGE: a filter or sort that reaches a property with
PreventFilter or PreventSort through arithmetic, the right side, a
property list, another letter case, the member name of a property with
a query name, a derived property, or a custom operation no longer
filters or sorts by that property. The clause follows
IgnoredClauseBehavior, and the sort is skipped.
@pdevito3
pdevito3 force-pushed the fm/qk-breaking-prevent-bypass branch from fc9011c to a62bbeb Compare October 1, 2026 21:35
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