Skip to content

Commit 9165dc3

Browse files
Merge pull request #238 from components-web-app/feature/237-or-search-filter-andwhere
AND the OrSearchFilter's clauses against the rest of the query (#237)
2 parents 12d5f2f + 94149b7 commit 9165dc3

6 files changed

Lines changed: 269 additions & 69 deletions

File tree

‎CLAUDE.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,3 +1004,25 @@ The `email` sub-node had no default of its own, so `new_email_confirmation` and
10041004
Tests: `tests/DependencyInjection/ConfigurationTest.php` and `tests/DependencyInjection/SilverbackApiComponentsExtensionTest.php` (bare `ContainerBuilder`, no kernel boot — see the Risky/Infection warning above), `tests/Serializer/MappingLoader/TimestampedLoaderTest.php`, `tests/Serializer/Normalizer/TimestampedNormalizerTest.php`, and `features/timestamped/timestamped.feature` (PATCH scenarios for both the guarded and unguarded entity shapes).
10051005

10061006
> **Newly covering a large declarative file costs MSI.** These DI tests pulled `Configuration.php` and `SilverbackApiComponentsExtension.php` into the covered set for the first time, so their mutants started counting where `--only-covered` had been skipping them — MSI fell from 85% to 82% against an 80% gate. The fix was to broaden the extension test to assert the wiring it performs, not to exclude the files.
1007+
1008+
---
1009+
1010+
### #237 — `orWhere()` in a Doctrine filter silently discards every query-extension predicate ✓ **DONE**
1011+
1012+
`OrSearchFilter::addWhereByStrategy()` built its clauses with `$queryBuilder->orWhere(...)`. **Doctrine's `orWhere()` ORs against the entire accumulated WHERE, not just among the filter's own clauses.** AP4 registers `FilterExtension` at priority `-16` while this bundle's query extensions carry no explicit priority and therefore default to `0` — higher priority runs first, so the extensions add their predicates **before** the filter runs and the filter then ORs them away:
1013+
1014+
```
1015+
(liveAt IS NOT NULL AND liveAt <= :now) OR (o.path LIKE :path)
1016+
```
1017+
1018+
An anonymous `GET /_/routes?path=launch` therefore listed a route scheduled for 2999. The filter parameter is attacker-controlled, so the predicate being defeated is a security one. Confirmed empirically to defeat `RouteExtension` (own `liveAt`), `RouteAncestorGateResolver` (ancestor gate, #234) **and** `PublishableExtension` (an anonymous filtered collection returned every draft alongside the published resources).
1019+
1020+
**The general trap, not a one-off: never use `orWhere()` in an API Platform filter.** Anything already on the query — a publication gate, a draft exclusion, an application's own extension — is inside the left operand of that OR and is discarded the moment the filter matches. Collect the filter's own clauses and apply them with a **single `andWhere()`** wrapping one `Expr\Orx`, which is the shape API Platform's own `SearchFilter` uses.
1021+
1022+
**The accumulator must be shared across `addWhereByStrategy()` calls.** `AbstractFilter::apply()` calls `filterProperty()` once per query parameter, and each of those calls `addWhereByStrategy()` once — so an `Orx` built *inside* `addWhereByStrategy()` would turn multi-field search into AND and destroy the filter's whole purpose. `apply()` is overridden to clear the accumulator, delegate to the parent, then apply the single `andWhere()`; the accumulator is cleared again in a `finally` so no state survives the request under worker mode.
1023+
1024+
Doctrine's DDC-1237 handling in `Expr\Composite::processQueryPart()` parenthesises any part whose string contains ` OR ` / ` AND `, which is what keeps the `word_start` strategy (a two-`LIKE` string clause) from capturing the preceding predicate once it is ANDed on.
1025+
1026+
**`addWhereByStrategy()`'s five single-value branches are unreachable from `filterProperty()`** — `normalizeValues((array) $value, ...)` always hands it an array — but they were fixed too, since the method is `protected`.
1027+
1028+
Tests: `tests/Filter/OrSearchFilterTest.php` asserts the DQL shape across all five strategies (case-sensitive and `i`-prefixed, single and multi-value) plus cross-field OR, the `word_start` parenthesisation and non-leakage between two `apply()` calls; `features/main/or_search_filter.feature` covers the scheduled, draft and ancestor-gated route cases, the publishable draft case, and two guards proving the filter still ORs across fields and across multiple values for one field. `DummyOrSearchFilterable` had existed with no coverage of any kind, which is why this survived.

‎features/bootstrap/DoctrineContext.php‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
use Silverback\ApiComponentsBundle\Repository\User\UserRepositoryInterface;
4646
use Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\DummyComponent;
4747
use Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\DummyCustomTimestamped;
48+
use Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\DummyOrSearchFilterable;
4849
use Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\DummyPublishableComponent;
4950
use Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\DummyTimestamped;
5051
use Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\DummyTimestampedWithSerializationGroups;
@@ -1085,6 +1086,20 @@ public function thereIsALayout(string $reference = 'no-reference', ?string $crea
10851086
return $layout;
10861087
}
10871088

1089+
/**
1090+
* @Given there is a DummyOrSearchFilterable with field1 :field1 and field2 :field2
1091+
*/
1092+
public function thereIsADummyOrSearchFilterable(string $field1, string $field2): DummyOrSearchFilterable
1093+
{
1094+
$resource = new DummyOrSearchFilterable();
1095+
$resource->field1 = $field1;
1096+
$resource->field2 = $field2;
1097+
$this->manager->persist($resource);
1098+
$this->manager->flush();
1099+
1100+
return $resource;
1101+
}
1102+
10881103
/**
10891104
* @Given there is an empty PageData resource
10901105
*/
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
Feature: A search filter combines its own clauses with OR and ANDs them against the rest of the query
2+
In order that supplying a filter parameter cannot bypass a publication gate or a draft exclusion
3+
As an anonymous API consumer
4+
A filtered collection must apply the query extensions' predicates as well as the filter's own
5+
6+
Background:
7+
Given I add "Accept" header equal to "application/ld+json"
8+
And I add "Content-Type" header equal to "application/ld+json"
9+
10+
Scenario: A filtered anonymous route collection still excludes a scheduled route
11+
Given there is a Route "/launch" with a page
12+
And the Route "/launch" goes live at "2999-01-01T00:00:00+00:00"
13+
When I send a "GET" request to "/_/routes?path=launch"
14+
Then the response status code should be 200
15+
And the JSON node "member[0]" should not exist
16+
17+
Scenario: A filtered anonymous route collection still excludes a draft route
18+
Given there is a Route "/launch" with a page
19+
And the Route "/launch" has no go-live date
20+
When I send a "GET" request to "/_/routes?path=launch"
21+
Then the response status code should be 200
22+
And the JSON node "member[0]" should not exist
23+
24+
Scenario: A filtered anonymous route collection still excludes a route gated by its ancestor
25+
Given there is a PageData resource with the route path "/conference/programme" nested within the route "/conference"
26+
And the Route "/conference" goes live at "2999-01-01T00:00:00+00:00"
27+
When I send a "GET" request to "/_/routes?path=programme"
28+
Then the response status code should be 200
29+
And the JSON node "member[0]" should not exist
30+
31+
Scenario: A filtered anonymous route collection still returns a live route
32+
Given there is a Route "/launch" with a page
33+
When I send a "GET" request to "/_/routes?path=launch"
34+
Then the response status code should be 200
35+
And the JSON node "totalItems" should be equal to "1"
36+
And the JSON node "member[0].path" should be equal to the string "/launch"
37+
38+
Scenario: The filter still matches across fields with OR
39+
Given there is a DummyOrSearchFilterable with field1 "alpha" and field2 "beta"
40+
And there is a DummyOrSearchFilterable with field1 "gamma" and field2 "alpha"
41+
When I send a "GET" request to "/dummy_or_search_filterables?field1=alpha&field2=alpha"
42+
Then the response status code should be 200
43+
And the JSON node "totalItems" should be equal to "2"
44+
45+
Scenario: The filter still matches multiple values for one field with OR
46+
Given there is a DummyOrSearchFilterable with field1 "alpha" and field2 "beta"
47+
And there is a DummyOrSearchFilterable with field1 "gamma" and field2 "delta"
48+
And there is a DummyOrSearchFilterable with field1 "epsilon" and field2 "zeta"
49+
When I send a "GET" request to "/dummy_or_search_filterables?field1[]=alpha&field1[]=gamma"
50+
Then the response status code should be 200
51+
And the JSON node "totalItems" should be equal to "2"
52+
53+
@loginUser
54+
Scenario: A filtered collection does not expose a draft to a user without draft access
55+
Given there are 2 draft and published resources available
56+
When I send a "GET" request to "/component/dummy_publishable_components?reference=is"
57+
Then the response status code should be 200
58+
And the response should include the published resources only without the draftResources key

‎src/Filter/OrSearchFilter.php‎

Lines changed: 33 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ final class OrSearchFilter extends AbstractFilter implements SearchFilterInterfa
3737

3838
public const DOCTRINE_INTEGER_TYPE = Types::INTEGER;
3939

40+
/**
41+
* @var list<string>
42+
*/
43+
private array $orExpressions = [];
44+
4045
public function __construct(
4146
ManagerRegistry $managerRegistry,
4247
IriConverterInterface $iriConverter,
@@ -51,6 +56,21 @@ public function __construct(
5156
$this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor();
5257
}
5358

59+
public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
60+
{
61+
$this->orExpressions = [];
62+
63+
try {
64+
parent::apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
65+
66+
if ($this->orExpressions) {
67+
$queryBuilder->andWhere($queryBuilder->expr()->orX(...$this->orExpressions));
68+
}
69+
} finally {
70+
$this->orExpressions = [];
71+
}
72+
}
73+
5474
/**
5575
* {@inheritdoc}
5676
*/
@@ -182,75 +202,19 @@ protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuild
182202
{
183203
$wrapCase = $this->createWrapCase($caseSensitive);
184204
$valueParameter = $queryNameGenerator->generateParameterName($field);
185-
switch ($strategy) {
186-
case null:
187-
case self::STRATEGY_EXACT:
188-
if (\is_array($value)) {
189-
foreach ($value as $i => $v) {
190-
$queryBuilder
191-
->orWhere(\sprintf($wrapCase('%s.%s') . ' = ' . $wrapCase(':%s'), $alias, $field, $valueParameter . $i))
192-
->setParameter($valueParameter . $i, $v);
193-
}
194-
} else {
195-
$queryBuilder
196-
->orWhere(\sprintf($wrapCase('%s.%s') . ' = ' . $wrapCase(':%s'), $alias, $field, $valueParameter))
197-
->setParameter($valueParameter, $value);
198-
}
199-
break;
200-
case self::STRATEGY_PARTIAL:
201-
if (\is_array($value)) {
202-
foreach ($value as $i => $v) {
203-
$queryBuilder
204-
->orWhere(\sprintf($wrapCase('%s.%s') . ' LIKE ' . $wrapCase('CONCAT(\'%%\', :%s, \'%%\')'), $alias, $field, $valueParameter . $i))
205-
->setParameter($valueParameter . $i, $v);
206-
}
207-
} else {
208-
$queryBuilder
209-
->orWhere(\sprintf($wrapCase('%s.%s') . ' LIKE ' . $wrapCase('CONCAT(\'%%\', :%s, \'%%\')'), $alias, $field, $valueParameter))
210-
->setParameter($valueParameter, $value);
211-
}
212-
break;
213-
case self::STRATEGY_START:
214-
if (\is_array($value)) {
215-
foreach ($value as $i => $v) {
216-
$queryBuilder
217-
->orWhere(\sprintf($wrapCase('%s.%s') . ' LIKE ' . $wrapCase('CONCAT(:%s, \'%%\')'), $alias, $field, $valueParameter . $i))
218-
->setParameter($valueParameter . $i, $v);
219-
}
220-
} else {
221-
$queryBuilder
222-
->orWhere(\sprintf($wrapCase('%s.%s') . ' LIKE ' . $wrapCase('CONCAT(:%s, \'%%\')'), $alias, $field, $valueParameter))
223-
->setParameter($valueParameter, $value);
224-
}
225-
break;
226-
case self::STRATEGY_END:
227-
if (\is_array($value)) {
228-
foreach ($value as $i => $v) {
229-
$queryBuilder
230-
->orWhere(\sprintf($wrapCase('%s.%s') . ' LIKE ' . $wrapCase('CONCAT(\'%%\', :%s)'), $alias, $field, $valueParameter . $i))
231-
->setParameter($valueParameter . $i, $v);
232-
}
233-
} else {
234-
$queryBuilder
235-
->orWhere(\sprintf($wrapCase('%s.%s') . ' LIKE ' . $wrapCase('CONCAT(\'%%\', :%s)'), $alias, $field, $valueParameter))
236-
->setParameter($valueParameter, $value);
237-
}
238-
break;
239-
case self::STRATEGY_WORD_START:
240-
if (\is_array($value)) {
241-
foreach ($value as $i => $v) {
242-
$queryBuilder
243-
->orWhere(\sprintf($wrapCase('%1$s.%2$s') . ' LIKE ' . $wrapCase('CONCAT(:%3$s, \'%%\')') . ' OR ' . $wrapCase('%1$s.%2$s') . ' LIKE ' . $wrapCase('CONCAT(\'%% \', :%3$s, \'%%\')'), $alias, $field, $valueParameter . $i))
244-
->setParameter($valueParameter . $i, $v);
245-
}
246-
} else {
247-
$queryBuilder
248-
->orWhere(\sprintf($wrapCase('%1$s.%2$s') . ' LIKE ' . $wrapCase('CONCAT(:%3$s, \'%%\')') . ' OR ' . $wrapCase('%1$s.%2$s') . ' LIKE ' . $wrapCase('CONCAT(\'%% \', :%3$s, \'%%\')'), $alias, $field, $valueParameter))
249-
->setParameter($valueParameter, $value);
250-
}
251-
break;
252-
default:
253-
throw new InvalidArgumentException(\sprintf('strategy %s does not exist.', $strategy));
205+
206+
$format = match ($strategy) {
207+
null, self::STRATEGY_EXACT => $wrapCase('%1$s.%2$s') . ' = ' . $wrapCase(':%3$s'),
208+
self::STRATEGY_PARTIAL => $wrapCase('%1$s.%2$s') . ' LIKE ' . $wrapCase('CONCAT(\'%%\', :%3$s, \'%%\')'),
209+
self::STRATEGY_START => $wrapCase('%1$s.%2$s') . ' LIKE ' . $wrapCase('CONCAT(:%3$s, \'%%\')'),
210+
self::STRATEGY_END => $wrapCase('%1$s.%2$s') . ' LIKE ' . $wrapCase('CONCAT(\'%%\', :%3$s)'),
211+
self::STRATEGY_WORD_START => $wrapCase('%1$s.%2$s') . ' LIKE ' . $wrapCase('CONCAT(:%3$s, \'%%\')') . ' OR ' . $wrapCase('%1$s.%2$s') . ' LIKE ' . $wrapCase('CONCAT(\'%% \', :%3$s, \'%%\')'),
212+
default => throw new InvalidArgumentException(\sprintf('strategy %s does not exist.', $strategy)),
213+
};
214+
215+
foreach ((array) $value as $i => $v) {
216+
$this->orExpressions[] = \sprintf($format, $alias, $field, $valueParameter . $i);
217+
$queryBuilder->setParameter($valueParameter . $i, $v);
254218
}
255219
}
256220
}

0 commit comments

Comments
 (0)