From 3a31fdecbdcffb47e414d6a93771ee36621c4a22 Mon Sep 17 00:00:00 2001 From: eldadfux Date: Sat, 18 Jul 2026 22:21:48 +0100 Subject: [PATCH 1/8] feat: add opt-in case-insensitive matching to conditions Adds a `caseInsensitive` flag to `Condition` so value-based operators (equal, notEqual, contains, notContains, startsWith, endsWith and their negations) can compare strings without regard to letter casing. This is useful for attributes whose values are case-insensitive by nature, such as ISO country codes, where a rule authored with `il` should still match a request resolved as `IL`. - Flag defaults to false, preserving existing strict comparison behavior - Round-trips through fromArray()/toArray()/encode()/decode() - Only emitted in toArray() when enabled to keep payloads compact Co-authored-by: Cursor --- src/Condition.php | 92 ++++++++++++++++++++++++++++++----------- tests/ConditionTest.php | 56 +++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 24 deletions(-) diff --git a/src/Condition.php b/src/Condition.php index f42de31..93799d7 100644 --- a/src/Condition.php +++ b/src/Condition.php @@ -76,10 +76,18 @@ class Condition */ private array $values; + /** + * When enabled, string comparisons for value based operators + * (equal, contains, startsWith, endsWith and their negations) are + * performed case-insensitively. Useful for attributes whose values are + * case-insensitive by nature such as ISO country codes. + */ + private bool $caseInsensitive; + /** * @param array $values */ - public function __construct(string $method, string $attribute = '', array $values = []) + public function __construct(string $method, string $attribute = '', array $values = [], bool $caseInsensitive = false) { if (!self::isMethod($method)) { throw new ConditionException("Unsupported condition method: {$method}"); @@ -88,6 +96,7 @@ public function __construct(string $method, string $attribute = '', array $value $this->method = $method; $this->attribute = $attribute; $this->values = $this->normalizeValues($values); + $this->caseInsensitive = $caseInsensitive; } public function __clone(): void @@ -122,6 +131,11 @@ public function isLogical(): bool return \in_array($this->method, self::LOGICAL_TYPES, true); } + public function isCaseInsensitive(): bool + { + return $this->caseInsensitive; + } + public static function isMethod(string $value): bool { return \in_array($value, self::TYPES, true); @@ -153,6 +167,7 @@ public static function fromArray(array $payload): self $method = $payload['method'] ?? ''; $attribute = $payload['attribute'] ?? ''; $values = $payload['values'] ?? []; + $caseInsensitive = $payload['caseInsensitive'] ?? false; if (!\is_string($method)) { throw new ConditionException('Invalid condition method definition.'); @@ -166,6 +181,10 @@ public static function fromArray(array $payload): self throw new ConditionException('Invalid condition values definition.'); } + if (!\is_bool($caseInsensitive)) { + throw new ConditionException('Invalid condition caseInsensitive definition.'); + } + if (\in_array($method, self::LOGICAL_TYPES, true)) { $values = array_map( static function (mixed $value): self { @@ -179,7 +198,7 @@ static function (mixed $value): self { ); } - return new self($method, $attribute, $values); + return new self($method, $attribute, $values, $caseInsensitive); } /** @@ -195,7 +214,8 @@ public static function fromArrays(array $conditions): array * @return array{ * method: string, * attribute?: string, - * values: array + * values: array, + * caseInsensitive?: bool * } */ public function toArray(): array @@ -215,6 +235,10 @@ public function toArray(): array $result['values'] = $this->values; } + if ($this->caseInsensitive) { + $result['caseInsensitive'] = true; + } + return $result; } @@ -235,14 +259,14 @@ public function encode(): string /** * @param array $values */ - public static function equal(string $attribute, array $values): self + public static function equal(string $attribute, array $values, bool $caseInsensitive = false): self { - return new self(self::TYPE_EQUAL, $attribute, $values); + return new self(self::TYPE_EQUAL, $attribute, $values, $caseInsensitive); } - public static function notEqual(string $attribute, string|int|float|bool $value): self + public static function notEqual(string $attribute, string|int|float|bool $value, bool $caseInsensitive = false): self { - return new self(self::TYPE_NOT_EQUAL, $attribute, [$value]); + return new self(self::TYPE_NOT_EQUAL, $attribute, [$value], $caseInsensitive); } public static function lessThan(string $attribute, string|int|float $value): self @@ -268,17 +292,17 @@ public static function greaterThanEqual(string $attribute, string|int|float $val /** * @param array $values */ - public static function contains(string $attribute, array $values): self + public static function contains(string $attribute, array $values, bool $caseInsensitive = false): self { - return new self(self::TYPE_CONTAINS, $attribute, $values); + return new self(self::TYPE_CONTAINS, $attribute, $values, $caseInsensitive); } /** * @param array $values */ - public static function notContains(string $attribute, array $values): self + public static function notContains(string $attribute, array $values, bool $caseInsensitive = false): self { - return new self(self::TYPE_NOT_CONTAINS, $attribute, $values); + return new self(self::TYPE_NOT_CONTAINS, $attribute, $values, $caseInsensitive); } public static function between(string $attribute, string|int|float $start, string|int|float $end): self @@ -291,24 +315,24 @@ public static function notBetween(string $attribute, string|int|float $start, st return new self(self::TYPE_NOT_BETWEEN, $attribute, [$start, $end]); } - public static function startsWith(string $attribute, string $value): self + public static function startsWith(string $attribute, string $value, bool $caseInsensitive = false): self { - return new self(self::TYPE_STARTS_WITH, $attribute, [$value]); + return new self(self::TYPE_STARTS_WITH, $attribute, [$value], $caseInsensitive); } - public static function notStartsWith(string $attribute, string $value): self + public static function notStartsWith(string $attribute, string $value, bool $caseInsensitive = false): self { - return new self(self::TYPE_NOT_STARTS_WITH, $attribute, [$value]); + return new self(self::TYPE_NOT_STARTS_WITH, $attribute, [$value], $caseInsensitive); } - public static function endsWith(string $attribute, string $value): self + public static function endsWith(string $attribute, string $value, bool $caseInsensitive = false): self { - return new self(self::TYPE_ENDS_WITH, $attribute, [$value]); + return new self(self::TYPE_ENDS_WITH, $attribute, [$value], $caseInsensitive); } - public static function notEndsWith(string $attribute, string $value): self + public static function notEndsWith(string $attribute, string $value, bool $caseInsensitive = false): self { - return new self(self::TYPE_NOT_ENDS_WITH, $attribute, [$value]); + return new self(self::TYPE_NOT_ENDS_WITH, $attribute, [$value], $caseInsensitive); } public static function isNull(string $attribute): self @@ -420,8 +444,10 @@ private function matchesLogical(array $attributes): bool private function matchesEqual(mixed $value): bool { + $value = $this->normalizeComparable($value); + foreach ($this->values as $expected) { - if ($expected === $value) { + if ($this->normalizeComparable($expected) === $value) { return true; } } @@ -435,12 +461,17 @@ private function matchesEqual(mixed $value): bool private function matchesContains(mixed $value, array $needles): bool { if (\is_array($value)) { - return \count(array_intersect($value, $needles)) > 0; + $haystack = array_map(fn (mixed $item): mixed => $this->normalizeComparable($item), $value); + $needles = array_map(fn (mixed $needle): mixed => $this->normalizeComparable($needle), $needles); + + return \count(array_intersect($haystack, $needles)) > 0; } if (\is_string($value)) { + $value = $this->normalizeComparable($value); + foreach ($needles as $needle) { - if (\is_string($needle) && $needle !== '' && str_contains($value, $needle)) { + if (\is_string($needle) && $needle !== '' && str_contains($value, $this->normalizeComparable($needle))) { return true; } } @@ -481,7 +512,7 @@ private function matchesPrefix(mixed $value): bool return false; } - return str_starts_with($value, $prefix); + return str_starts_with($this->normalizeComparable($value), $this->normalizeComparable($prefix)); } private function matchesSuffix(mixed $value): bool @@ -492,7 +523,20 @@ private function matchesSuffix(mixed $value): bool return false; } - return str_ends_with($value, $suffix); + return str_ends_with($this->normalizeComparable($value), $this->normalizeComparable($suffix)); + } + + /** + * Lowercase string values when the condition is case-insensitive so that + * comparisons ignore letter casing. Non-string values are returned as-is. + */ + private function normalizeComparable(mixed $value): mixed + { + if ($this->caseInsensitive && \is_string($value)) { + return \mb_strtolower($value); + } + + return $value; } /** diff --git a/tests/ConditionTest.php b/tests/ConditionTest.php index aad0d25..2985f42 100644 --- a/tests/ConditionTest.php +++ b/tests/ConditionTest.php @@ -170,6 +170,62 @@ public function testConditionSerializationRoundTrip(): void $this->assertFalse($parsed->matches(['ip' => '127.0.0.1', 'path' => '/web'])); } + public function testCaseInsensitiveMatching(): void + { + $equal = Condition::equal('country', ['IL', 'US'], caseInsensitive: true); + $this->assertTrue($equal->matches(['country' => 'il'])); + $this->assertTrue($equal->matches(['country' => 'Us'])); + $this->assertFalse($equal->matches(['country' => 'de'])); + + $caseSensitive = Condition::equal('country', ['IL']); + $this->assertFalse($caseSensitive->matches(['country' => 'il'])); + $this->assertTrue($caseSensitive->matches(['country' => 'IL'])); + + $notEqual = Condition::notEqual('country', 'IL', caseInsensitive: true); + $this->assertFalse($notEqual->matches(['country' => 'il'])); + $this->assertTrue($notEqual->matches(['country' => 'de'])); + + $contains = Condition::contains('path', ['/API'], caseInsensitive: true); + $this->assertTrue($contains->matches(['path' => '/api/v1'])); + + $arrayContains = Condition::contains('tags', ['SECURITY'], caseInsensitive: true); + $this->assertTrue($arrayContains->matches(['tags' => ['security', 'waf']])); + + $startsWith = Condition::startsWith('path', '/Api', caseInsensitive: true); + $this->assertTrue($startsWith->matches(['path' => '/api/v1'])); + + $endsWith = Condition::endsWith('path', '.JSON', caseInsensitive: true); + $this->assertTrue($endsWith->matches(['path' => '/status.json'])); + } + + public function testCaseInsensitiveSerializationRoundTrip(): void + { + $condition = Condition::equal('country', ['IL'], caseInsensitive: true); + + $array = $condition->toArray(); + $this->assertTrue($array['caseInsensitive']); + + $parsed = Condition::decode($condition->encode()); + $this->assertTrue($parsed->isCaseInsensitive()); + $this->assertTrue($parsed->matches(['country' => 'il'])); + + $plain = Condition::equal('country', ['IL']); + $this->assertArrayNotHasKey('caseInsensitive', $plain->toArray()); + $this->assertFalse($plain->isCaseInsensitive()); + } + + public function testInvalidCaseInsensitiveThrowsException(): void + { + $this->expectException(ConditionException::class); + + Condition::fromArray([ + 'method' => 'equal', + 'attribute' => 'country', + 'values' => ['IL'], + 'caseInsensitive' => 'yes', + ]); + } + public function testInvalidMethodThrowsException(): void { $this->expectException(ConditionException::class); From 5b7e0e4bc5332d965809512495471873f7106010 Mon Sep 17 00:00:00 2001 From: eldadfux Date: Sat, 18 Jul 2026 22:45:40 +0100 Subject: [PATCH 2/8] feat: normalize condition attribute names Canonicalize attributes so requestPath/RequestPath/path all resolve to the same Firewall key (mirrors Firewall request* aliases + lowercase). Co-authored-by: Cursor --- src/Condition.php | 21 ++++++++++++++++++++- tests/ConditionTest.php | 23 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/Condition.php b/src/Condition.php index 93799d7..529fa03 100644 --- a/src/Condition.php +++ b/src/Condition.php @@ -94,7 +94,7 @@ public function __construct(string $method, string $attribute = '', array $value } $this->method = $method; - $this->attribute = $attribute; + $this->attribute = self::normalizeAttribute($attribute); $this->values = $this->normalizeValues($values); $this->caseInsensitive = $caseInsensitive; } @@ -141,6 +141,25 @@ public static function isMethod(string $value): bool return \in_array($value, self::TYPES, true); } + /** + * Canonicalize attribute names so conditions authored as `requestPath`, + * `RequestPath`, or `path` all resolve against the same Firewall attribute key. + * + * Mirrors Firewall::normalizeRequestKey() and its lowercase alias. + */ + public static function normalizeAttribute(string $attribute): string + { + if ($attribute === '') { + return ''; + } + + if (stripos($attribute, 'request') === 0 && \strlen($attribute) > 7) { + $attribute = \lcfirst(\substr($attribute, 7)); + } + + return \strtolower($attribute); + } + /** * Decode a JSON encoded condition string. */ diff --git a/tests/ConditionTest.php b/tests/ConditionTest.php index 2985f42..71b85f7 100644 --- a/tests/ConditionTest.php +++ b/tests/ConditionTest.php @@ -170,6 +170,29 @@ public function testConditionSerializationRoundTrip(): void $this->assertFalse($parsed->matches(['ip' => '127.0.0.1', 'path' => '/web'])); } + public function testAttributeNormalization(): void + { + $fromAlias = Condition::fromArray([ + 'method' => 'equal', + 'attribute' => 'requestPath', + 'values' => ['/admin'], + ]); + $this->assertSame('path', $fromAlias->getAttribute()); + $this->assertTrue($fromAlias->matches(['path' => '/admin'])); + + $fromCased = Condition::equal('Country', ['IL']); + $this->assertSame('country', $fromCased->getAttribute()); + $this->assertTrue($fromCased->matches(['country' => 'IL'])); + + $fromRequestCountry = Condition::fromArray([ + 'method' => 'equal', + 'attribute' => 'requestCountry', + 'values' => ['US'], + ]); + $this->assertSame('country', $fromRequestCountry->getAttribute()); + $this->assertSame('country', $fromRequestCountry->toArray()['attribute']); + } + public function testCaseInsensitiveMatching(): void { $equal = Condition::equal('country', ['IL', 'US'], caseInsensitive: true); From 7dfcedf83f3da4bc7f7ec6b05ce6dcde0a95e158 Mon Sep 17 00:00:00 2001 From: eldadfux Date: Sat, 18 Jul 2026 22:52:44 +0100 Subject: [PATCH 3/8] Revert "feat: normalize condition attribute names" This reverts commit 5b7e0e4bc5332d965809512495471873f7106010. --- src/Condition.php | 21 +-------------------- tests/ConditionTest.php | 23 ----------------------- 2 files changed, 1 insertion(+), 43 deletions(-) diff --git a/src/Condition.php b/src/Condition.php index 529fa03..93799d7 100644 --- a/src/Condition.php +++ b/src/Condition.php @@ -94,7 +94,7 @@ public function __construct(string $method, string $attribute = '', array $value } $this->method = $method; - $this->attribute = self::normalizeAttribute($attribute); + $this->attribute = $attribute; $this->values = $this->normalizeValues($values); $this->caseInsensitive = $caseInsensitive; } @@ -141,25 +141,6 @@ public static function isMethod(string $value): bool return \in_array($value, self::TYPES, true); } - /** - * Canonicalize attribute names so conditions authored as `requestPath`, - * `RequestPath`, or `path` all resolve against the same Firewall attribute key. - * - * Mirrors Firewall::normalizeRequestKey() and its lowercase alias. - */ - public static function normalizeAttribute(string $attribute): string - { - if ($attribute === '') { - return ''; - } - - if (stripos($attribute, 'request') === 0 && \strlen($attribute) > 7) { - $attribute = \lcfirst(\substr($attribute, 7)); - } - - return \strtolower($attribute); - } - /** * Decode a JSON encoded condition string. */ diff --git a/tests/ConditionTest.php b/tests/ConditionTest.php index 71b85f7..2985f42 100644 --- a/tests/ConditionTest.php +++ b/tests/ConditionTest.php @@ -170,29 +170,6 @@ public function testConditionSerializationRoundTrip(): void $this->assertFalse($parsed->matches(['ip' => '127.0.0.1', 'path' => '/web'])); } - public function testAttributeNormalization(): void - { - $fromAlias = Condition::fromArray([ - 'method' => 'equal', - 'attribute' => 'requestPath', - 'values' => ['/admin'], - ]); - $this->assertSame('path', $fromAlias->getAttribute()); - $this->assertTrue($fromAlias->matches(['path' => '/admin'])); - - $fromCased = Condition::equal('Country', ['IL']); - $this->assertSame('country', $fromCased->getAttribute()); - $this->assertTrue($fromCased->matches(['country' => 'IL'])); - - $fromRequestCountry = Condition::fromArray([ - 'method' => 'equal', - 'attribute' => 'requestCountry', - 'values' => ['US'], - ]); - $this->assertSame('country', $fromRequestCountry->getAttribute()); - $this->assertSame('country', $fromRequestCountry->toArray()['attribute']); - } - public function testCaseInsensitiveMatching(): void { $equal = Condition::equal('country', ['IL', 'US'], caseInsensitive: true); From 448db55a6e383e471413eb5fc0c082e386e0e166 Mon Sep 17 00:00:00 2001 From: eldadfux Date: Sat, 18 Jul 2026 22:53:20 +0100 Subject: [PATCH 4/8] feat: evaluate conditions through populated firewall attributes Keep attribute alias resolution inside Firewall so consumers populate request values through setAttribute() instead of rewriting condition names. Co-authored-by: Cursor --- src/Firewall.php | 16 ++++++++++++++++ tests/FirewallTest.php | 20 ++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/Firewall.php b/src/Firewall.php index bc57b8a..5daaf66 100644 --- a/src/Firewall.php +++ b/src/Firewall.php @@ -42,6 +42,22 @@ public function getAttribute(string $name, mixed $default = null): mixed return $this->attributes[$name] ?? $default; } + /** + * Evaluate conditions against attributes populated through setAttribute(). + * + * @param array $conditions + */ + public function matches(array $conditions): bool + { + foreach ($conditions as $condition) { + if (!$condition->matches($this->attributes)) { + return false; + } + } + + return true; + } + public function addRule(Rule $rule): self { $this->rules[] = $rule; diff --git a/tests/FirewallTest.php b/tests/FirewallTest.php index d5a394c..cd12729 100644 --- a/tests/FirewallTest.php +++ b/tests/FirewallTest.php @@ -11,6 +11,26 @@ class FirewallTest extends TestCase { + public function testMatchesConditionsUsingPopulatedRequestAttributes(): void + { + $firewall = new Firewall(); + $firewall->setAttributes([ + 'requestIP' => '127.0.0.1', + 'requestPath' => '/v1/locale', + 'requestCountry' => 'IL', + ]); + + $this->assertTrue($firewall->matches([ + Condition::equal('ip', ['127.0.0.1']), + Condition::contains('path', ['/v1']), + Condition::equal('country', ['il'], caseInsensitive: true), + ])); + + $this->assertFalse($firewall->matches([ + Condition::equal('country', ['US'], caseInsensitive: true), + ])); + } + public function testRuleOrder(): void { $firewall = new Firewall(); From 67ed463c63e29f46d699de91846ec9d992413134 Mon Sep 17 00:00:00 2001 From: eldadfux Date: Sat, 18 Jul 2026 22:57:09 +0100 Subject: [PATCH 5/8] Revert "feat: add opt-in case-insensitive matching to conditions" This reverts commit 3a31fdecbdcffb47e414d6a93771ee36621c4a22. --- src/Condition.php | 92 +++++++++++------------------------------ tests/ConditionTest.php | 56 ------------------------- 2 files changed, 24 insertions(+), 124 deletions(-) diff --git a/src/Condition.php b/src/Condition.php index 93799d7..f42de31 100644 --- a/src/Condition.php +++ b/src/Condition.php @@ -76,18 +76,10 @@ class Condition */ private array $values; - /** - * When enabled, string comparisons for value based operators - * (equal, contains, startsWith, endsWith and their negations) are - * performed case-insensitively. Useful for attributes whose values are - * case-insensitive by nature such as ISO country codes. - */ - private bool $caseInsensitive; - /** * @param array $values */ - public function __construct(string $method, string $attribute = '', array $values = [], bool $caseInsensitive = false) + public function __construct(string $method, string $attribute = '', array $values = []) { if (!self::isMethod($method)) { throw new ConditionException("Unsupported condition method: {$method}"); @@ -96,7 +88,6 @@ public function __construct(string $method, string $attribute = '', array $value $this->method = $method; $this->attribute = $attribute; $this->values = $this->normalizeValues($values); - $this->caseInsensitive = $caseInsensitive; } public function __clone(): void @@ -131,11 +122,6 @@ public function isLogical(): bool return \in_array($this->method, self::LOGICAL_TYPES, true); } - public function isCaseInsensitive(): bool - { - return $this->caseInsensitive; - } - public static function isMethod(string $value): bool { return \in_array($value, self::TYPES, true); @@ -167,7 +153,6 @@ public static function fromArray(array $payload): self $method = $payload['method'] ?? ''; $attribute = $payload['attribute'] ?? ''; $values = $payload['values'] ?? []; - $caseInsensitive = $payload['caseInsensitive'] ?? false; if (!\is_string($method)) { throw new ConditionException('Invalid condition method definition.'); @@ -181,10 +166,6 @@ public static function fromArray(array $payload): self throw new ConditionException('Invalid condition values definition.'); } - if (!\is_bool($caseInsensitive)) { - throw new ConditionException('Invalid condition caseInsensitive definition.'); - } - if (\in_array($method, self::LOGICAL_TYPES, true)) { $values = array_map( static function (mixed $value): self { @@ -198,7 +179,7 @@ static function (mixed $value): self { ); } - return new self($method, $attribute, $values, $caseInsensitive); + return new self($method, $attribute, $values); } /** @@ -214,8 +195,7 @@ public static function fromArrays(array $conditions): array * @return array{ * method: string, * attribute?: string, - * values: array, - * caseInsensitive?: bool + * values: array * } */ public function toArray(): array @@ -235,10 +215,6 @@ public function toArray(): array $result['values'] = $this->values; } - if ($this->caseInsensitive) { - $result['caseInsensitive'] = true; - } - return $result; } @@ -259,14 +235,14 @@ public function encode(): string /** * @param array $values */ - public static function equal(string $attribute, array $values, bool $caseInsensitive = false): self + public static function equal(string $attribute, array $values): self { - return new self(self::TYPE_EQUAL, $attribute, $values, $caseInsensitive); + return new self(self::TYPE_EQUAL, $attribute, $values); } - public static function notEqual(string $attribute, string|int|float|bool $value, bool $caseInsensitive = false): self + public static function notEqual(string $attribute, string|int|float|bool $value): self { - return new self(self::TYPE_NOT_EQUAL, $attribute, [$value], $caseInsensitive); + return new self(self::TYPE_NOT_EQUAL, $attribute, [$value]); } public static function lessThan(string $attribute, string|int|float $value): self @@ -292,17 +268,17 @@ public static function greaterThanEqual(string $attribute, string|int|float $val /** * @param array $values */ - public static function contains(string $attribute, array $values, bool $caseInsensitive = false): self + public static function contains(string $attribute, array $values): self { - return new self(self::TYPE_CONTAINS, $attribute, $values, $caseInsensitive); + return new self(self::TYPE_CONTAINS, $attribute, $values); } /** * @param array $values */ - public static function notContains(string $attribute, array $values, bool $caseInsensitive = false): self + public static function notContains(string $attribute, array $values): self { - return new self(self::TYPE_NOT_CONTAINS, $attribute, $values, $caseInsensitive); + return new self(self::TYPE_NOT_CONTAINS, $attribute, $values); } public static function between(string $attribute, string|int|float $start, string|int|float $end): self @@ -315,24 +291,24 @@ public static function notBetween(string $attribute, string|int|float $start, st return new self(self::TYPE_NOT_BETWEEN, $attribute, [$start, $end]); } - public static function startsWith(string $attribute, string $value, bool $caseInsensitive = false): self + public static function startsWith(string $attribute, string $value): self { - return new self(self::TYPE_STARTS_WITH, $attribute, [$value], $caseInsensitive); + return new self(self::TYPE_STARTS_WITH, $attribute, [$value]); } - public static function notStartsWith(string $attribute, string $value, bool $caseInsensitive = false): self + public static function notStartsWith(string $attribute, string $value): self { - return new self(self::TYPE_NOT_STARTS_WITH, $attribute, [$value], $caseInsensitive); + return new self(self::TYPE_NOT_STARTS_WITH, $attribute, [$value]); } - public static function endsWith(string $attribute, string $value, bool $caseInsensitive = false): self + public static function endsWith(string $attribute, string $value): self { - return new self(self::TYPE_ENDS_WITH, $attribute, [$value], $caseInsensitive); + return new self(self::TYPE_ENDS_WITH, $attribute, [$value]); } - public static function notEndsWith(string $attribute, string $value, bool $caseInsensitive = false): self + public static function notEndsWith(string $attribute, string $value): self { - return new self(self::TYPE_NOT_ENDS_WITH, $attribute, [$value], $caseInsensitive); + return new self(self::TYPE_NOT_ENDS_WITH, $attribute, [$value]); } public static function isNull(string $attribute): self @@ -444,10 +420,8 @@ private function matchesLogical(array $attributes): bool private function matchesEqual(mixed $value): bool { - $value = $this->normalizeComparable($value); - foreach ($this->values as $expected) { - if ($this->normalizeComparable($expected) === $value) { + if ($expected === $value) { return true; } } @@ -461,17 +435,12 @@ private function matchesEqual(mixed $value): bool private function matchesContains(mixed $value, array $needles): bool { if (\is_array($value)) { - $haystack = array_map(fn (mixed $item): mixed => $this->normalizeComparable($item), $value); - $needles = array_map(fn (mixed $needle): mixed => $this->normalizeComparable($needle), $needles); - - return \count(array_intersect($haystack, $needles)) > 0; + return \count(array_intersect($value, $needles)) > 0; } if (\is_string($value)) { - $value = $this->normalizeComparable($value); - foreach ($needles as $needle) { - if (\is_string($needle) && $needle !== '' && str_contains($value, $this->normalizeComparable($needle))) { + if (\is_string($needle) && $needle !== '' && str_contains($value, $needle)) { return true; } } @@ -512,7 +481,7 @@ private function matchesPrefix(mixed $value): bool return false; } - return str_starts_with($this->normalizeComparable($value), $this->normalizeComparable($prefix)); + return str_starts_with($value, $prefix); } private function matchesSuffix(mixed $value): bool @@ -523,20 +492,7 @@ private function matchesSuffix(mixed $value): bool return false; } - return str_ends_with($this->normalizeComparable($value), $this->normalizeComparable($suffix)); - } - - /** - * Lowercase string values when the condition is case-insensitive so that - * comparisons ignore letter casing. Non-string values are returned as-is. - */ - private function normalizeComparable(mixed $value): mixed - { - if ($this->caseInsensitive && \is_string($value)) { - return \mb_strtolower($value); - } - - return $value; + return str_ends_with($value, $suffix); } /** diff --git a/tests/ConditionTest.php b/tests/ConditionTest.php index 2985f42..aad0d25 100644 --- a/tests/ConditionTest.php +++ b/tests/ConditionTest.php @@ -170,62 +170,6 @@ public function testConditionSerializationRoundTrip(): void $this->assertFalse($parsed->matches(['ip' => '127.0.0.1', 'path' => '/web'])); } - public function testCaseInsensitiveMatching(): void - { - $equal = Condition::equal('country', ['IL', 'US'], caseInsensitive: true); - $this->assertTrue($equal->matches(['country' => 'il'])); - $this->assertTrue($equal->matches(['country' => 'Us'])); - $this->assertFalse($equal->matches(['country' => 'de'])); - - $caseSensitive = Condition::equal('country', ['IL']); - $this->assertFalse($caseSensitive->matches(['country' => 'il'])); - $this->assertTrue($caseSensitive->matches(['country' => 'IL'])); - - $notEqual = Condition::notEqual('country', 'IL', caseInsensitive: true); - $this->assertFalse($notEqual->matches(['country' => 'il'])); - $this->assertTrue($notEqual->matches(['country' => 'de'])); - - $contains = Condition::contains('path', ['/API'], caseInsensitive: true); - $this->assertTrue($contains->matches(['path' => '/api/v1'])); - - $arrayContains = Condition::contains('tags', ['SECURITY'], caseInsensitive: true); - $this->assertTrue($arrayContains->matches(['tags' => ['security', 'waf']])); - - $startsWith = Condition::startsWith('path', '/Api', caseInsensitive: true); - $this->assertTrue($startsWith->matches(['path' => '/api/v1'])); - - $endsWith = Condition::endsWith('path', '.JSON', caseInsensitive: true); - $this->assertTrue($endsWith->matches(['path' => '/status.json'])); - } - - public function testCaseInsensitiveSerializationRoundTrip(): void - { - $condition = Condition::equal('country', ['IL'], caseInsensitive: true); - - $array = $condition->toArray(); - $this->assertTrue($array['caseInsensitive']); - - $parsed = Condition::decode($condition->encode()); - $this->assertTrue($parsed->isCaseInsensitive()); - $this->assertTrue($parsed->matches(['country' => 'il'])); - - $plain = Condition::equal('country', ['IL']); - $this->assertArrayNotHasKey('caseInsensitive', $plain->toArray()); - $this->assertFalse($plain->isCaseInsensitive()); - } - - public function testInvalidCaseInsensitiveThrowsException(): void - { - $this->expectException(ConditionException::class); - - Condition::fromArray([ - 'method' => 'equal', - 'attribute' => 'country', - 'values' => ['IL'], - 'caseInsensitive' => 'yes', - ]); - } - public function testInvalidMethodThrowsException(): void { $this->expectException(ConditionException::class); From bd202e8aaa554a29ef374affdc1b73bbcf13dac9 Mon Sep 17 00:00:00 2001 From: eldadfux Date: Sat, 18 Jul 2026 22:57:52 +0100 Subject: [PATCH 6/8] test: use strict country matching in firewall evaluation Keep condition comparisons case-sensitive while validating that request values flow through Firewall attributes. Co-authored-by: Cursor --- tests/FirewallTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/FirewallTest.php b/tests/FirewallTest.php index cd12729..4589550 100644 --- a/tests/FirewallTest.php +++ b/tests/FirewallTest.php @@ -23,11 +23,11 @@ public function testMatchesConditionsUsingPopulatedRequestAttributes(): void $this->assertTrue($firewall->matches([ Condition::equal('ip', ['127.0.0.1']), Condition::contains('path', ['/v1']), - Condition::equal('country', ['il'], caseInsensitive: true), + Condition::equal('country', ['IL']), ])); $this->assertFalse($firewall->matches([ - Condition::equal('country', ['US'], caseInsensitive: true), + Condition::equal('country', ['US']), ])); } From 5865b96c4cb685dd560aa2dc66c98fdd8c72cd9f Mon Sep 17 00:00:00 2001 From: eldadfux Date: Sat, 18 Jul 2026 23:10:43 +0100 Subject: [PATCH 7/8] refactor: evaluate rules through verify and lastMatchedRule Remove the conditions-only matches() helper. Consumers populate attributes, register ordered rules, call verify(), then inspect getLastMatchedRule(). Co-authored-by: Cursor --- src/Firewall.php | 23 ++++++----------------- tests/FirewallTest.php | 17 +++++++++++++---- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/Firewall.php b/src/Firewall.php index 5daaf66..59681d1 100644 --- a/src/Firewall.php +++ b/src/Firewall.php @@ -42,22 +42,6 @@ public function getAttribute(string $name, mixed $default = null): mixed return $this->attributes[$name] ?? $default; } - /** - * Evaluate conditions against attributes populated through setAttribute(). - * - * @param array $conditions - */ - public function matches(array $conditions): bool - { - foreach ($conditions as $condition) { - if (!$condition->matches($this->attributes)) { - return false; - } - } - - return true; - } - public function addRule(Rule $rule): self { $this->rules[] = $rule; @@ -96,7 +80,12 @@ public function getLastMatchedRule(): ?Rule } /** - * Evaluate the registered rules and return true when the request should be allowed. + * Evaluate registered rules in order against populated attributes. + * + * Sets the matched rule via getLastMatchedRule() when a rule's conditions + * match. Returns whether that rule's action allows the request to continue + * (bypass/rateLimit) or should be blocked (deny/challenge/redirect). + * Returns false when no rule matches. */ public function verify(): bool { diff --git a/tests/FirewallTest.php b/tests/FirewallTest.php index 4589550..b3c41ac 100644 --- a/tests/FirewallTest.php +++ b/tests/FirewallTest.php @@ -11,7 +11,7 @@ class FirewallTest extends TestCase { - public function testMatchesConditionsUsingPopulatedRequestAttributes(): void + public function testVerifyUsesPopulatedRequestAttributesAndExposesMatchedRule(): void { $firewall = new Firewall(); $firewall->setAttributes([ @@ -20,15 +20,24 @@ public function testMatchesConditionsUsingPopulatedRequestAttributes(): void 'requestCountry' => 'IL', ]); - $this->assertTrue($firewall->matches([ + $deny = new Deny([ Condition::equal('ip', ['127.0.0.1']), Condition::contains('path', ['/v1']), Condition::equal('country', ['IL']), - ])); + ]); + + $firewall->addRule($deny); + + $this->assertFalse($firewall->verify()); + $this->assertSame($deny, $firewall->getLastMatchedRule()); - $this->assertFalse($firewall->matches([ + $firewall->clearRules(); + $firewall->addRule(new Deny([ Condition::equal('country', ['US']), ])); + + $this->assertFalse($firewall->verify()); + $this->assertNull($firewall->getLastMatchedRule()); } public function testRuleOrder(): void From ba562e560ee180ae161340628f746d0ca762c6b4 Mon Sep 17 00:00:00 2001 From: eldadfux Date: Sat, 18 Jul 2026 23:17:10 +0100 Subject: [PATCH 8/8] feat: allow rules to carry an optional identifier Consumers can attach a stable id to matched rules so application-layer metadata does not require a separate object map. Co-authored-by: Cursor --- src/Rule.php | 14 ++++++++++++++ tests/FirewallTest.php | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/Rule.php b/src/Rule.php index 7df6f2b..4cc6370 100644 --- a/src/Rule.php +++ b/src/Rule.php @@ -15,6 +15,8 @@ abstract class Rule */ protected array $conditions = []; + private ?string $id = null; + /** * @param array> $conditions */ @@ -34,6 +36,18 @@ static function (Condition|array $condition): Condition { abstract public function getAction(): string; + public function setId(string $id): self + { + $this->id = $id; + + return $this; + } + + public function getId(): ?string + { + return $this->id; + } + /** * @return array */ diff --git a/tests/FirewallTest.php b/tests/FirewallTest.php index b3c41ac..5dc166c 100644 --- a/tests/FirewallTest.php +++ b/tests/FirewallTest.php @@ -92,4 +92,18 @@ public function testRateLimitMetadata(): void $this->assertSame(2, $matched->getLimit()); $this->assertSame(60, $matched->getInterval()); } + + public function testRuleIdentifierRoundTrip(): void + { + $rule = (new Deny([ + Condition::equal('ip', ['127.0.0.1']), + ]))->setId('rule_abc'); + + $firewall = new Firewall(); + $firewall->setAttribute('requestIP', '127.0.0.1'); + $firewall->addRule($rule); + + $this->assertFalse($firewall->verify()); + $this->assertSame('rule_abc', $firewall->getLastMatchedRule()?->getId()); + } }