AND the OrSearchFilter's clauses against the rest of the query (#237) - #238
Merged
Merged
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #238 +/- ##
============================================
+ Coverage 87.81% 88.33% +0.51%
+ Complexity 2665 2657 -8
============================================
Files 258 258
Lines 7730 7695 -35
============================================
+ Hits 6788 6797 +9
+ Misses 942 898 -44
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
addWhereByStrategy() built its clauses with orWhere(), which ORs against the entire accumulated WHERE rather than among the filter's own clauses. Query extensions default to priority 0 while FilterExtension is -16, so extensions add their predicates first and the filter ORed them away. A filter parameter is attacker-controlled, so the discarded predicate is a security one. Verified on main: anonymous GET /_/routes?path=launch returned a route scheduled for 2999, and a user without draft permission saw every draft in a filtered publishable collection. Clauses now accumulate into one Orx applied with a single andWhere(). The accumulator is shared across the per-field calls, since the method runs once per query parameter and building it per call would turn multi-field search into AND. Each strategy had a duplicate single-value branch that normalizeValues() made unreachable, so values are normalised to an array once and each strategy is a single format string.
silverbackdan
force-pushed
the
feature/237-or-search-filter-andwhere
branch
from
September 21, 2026 13:57
5b84426 to
94149b7
Compare
7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #237. Found while implementing #234 (PR #236).
The bug
OrSearchFilter::addWhereByStrategy()built its clauses with$queryBuilder->orWhere(...), which ORs against the entire accumulated WHERE rather than among the filter's own clauses.This bundle's query extensions carry no explicit priority (default
0) while API Platform'sFilterExtensionis-16, so the extensions add their predicates first and the filter then ORed them away:A filter parameter is attacker-controlled, so the predicate being discarded is a security one. On
main, anonymousGET /_/routes?path=launchreturned a route scheduled for 2999.The blast radius was wider than routes, and the issue named the wrong mechanism
The publishable case reproduces:
@loginUserwithout draft permission,GET /component/dummy_publishable_components?reference=isreturned 12 resources where 6 were expected — every draft surfaced alongside the published ones.But not via the
NOT IN (SELECT IDENTITY(o2.publishedResource) …)sub-select the issue names. That branch only runs for a caller who has draft access. The path that actually breaks isupdateQueryBuilderForUnauthorizedUsers()→PublicationDate::andWhereActive()(publishedAt IS NOT NULL AND publishedAt <= :now), and that is the predicate being ORed away. The conclusion in the issue was right; its stated mechanism was the admin-path one.The fix
All ten
orWhere()call sites (exact, partial, start, end, word_start × multi-value/single-value) append to a shared accumulator;apply()is overridden to clear it, delegate toparent::apply(), then apply the whole set as oneandWhere($qb->expr()->orX(...)).The accumulator is shared across the per-field calls, not built inside
addWhereByStrategy(). That method is invoked once per query parameter, so building it per call would turn multi-field search intoANDand silently destroy the filter's entire purpose. It is cleared at entry and in afinally, so nothing survives a request under worker mode — pinned bytest_clauses_from_one_request_do_not_leak_into_the_next.Doctrine's DDC-1237 handling in
Expr\Composite::processQueryPart()parenthesises theword_startclause (a two-LIKEstring containingOR) once it is ANDed on, so precedence is safe. There is an explicit test asserting that exact DQL rather than trusting it.Tests — failed first, for the right reason
Unit: 25 of 26 failed with DQL reading
o.publishedAt IS NOT NULL **OR** …where it must beAND.Behat: the 4 gating scenarios failed (
The node 'member[0]' exists,Expected 6 resources but received 12), while the 3 OR-semantics guards passed before and after — which is what proves the fix did not turn the filter into anANDor a no-op.No scenario changed behaviour, and here is why each was already safe
Every filtered-collection scenario in the suite was re-read:
/_/pages?reference=,?title=,?uiComponent=— all@loginAdmin, soRoutableExtensionshort-circuits and adds no predicate/_/pages?isTemplate=1— API Platform's ownSearchFilter, which already usesandWhere/_/layouts?reference=,?uiComponent=—Layoutis neitherRoutableInterfacenor#[Publishable]/_/routes?itemsPerPage=10— not a filter property, soOrSearchFilternever runs?published=true|false— read byPublishableExtensionfrom the filter context, not anOrSearchFilterpropertyThe one scenario that had been passing for the wrong reason was already rewritten in #236.
Two findings recorded
DummyOrSearchFilterablehad zero coverage of any kind — no unit test, no scenario — despite existing as a dedicated fixture. That absence is the direct reason this survived. It now has both.The five single-value branches are unreachable from
filterProperty():normalizeValues((array) $value, $property)always hands over an array, which is why a "single value" still generates the array-suffixed parameter name:field1_p10. They are fixed anyway since the method isprotected, but the class isfinal, so they are dead code and a candidate for collapsing.Behavioural change for upgraders
Filtered collections will return fewer rows wherever an extension predicate was previously being discarded. That is the point of the fix, but it warrants a release note. No migration, no config change.
🤖 Generated with Claude Code