From 69d83f6a940e887367f8e965436b6f8ea3bf6cf6 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Jul 2026 16:33:42 +0530 Subject: [PATCH 1/7] feat: add attribute type contract with IP/CIDR implementation Introduce Types\AttributeType, a tri-state extension point for typed condition matching (true/false = handled, null = fall back to default semantics), plus creation-time value validation. Types\IpType implements it for IP-valued attributes: equality conditions accept CIDR blocks (IPv4/IPv6, non-octet-aligned prefixes) alongside plain IPs, with the CIDR parsing/containment helpers kept private to the type. --- src/Types/AttributeType.php | 30 +++++++++ src/Types/IpType.php | 124 ++++++++++++++++++++++++++++++++++++ tests/Types/IpTypeTest.php | 107 +++++++++++++++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 src/Types/AttributeType.php create mode 100644 src/Types/IpType.php create mode 100644 tests/Types/IpTypeTest.php diff --git a/src/Types/AttributeType.php b/src/Types/AttributeType.php new file mode 100644 index 0000000..24582dc --- /dev/null +++ b/src/Types/AttributeType.php @@ -0,0 +1,30 @@ +/"). + */ + private static function isCidr(string $candidate): bool + { + return self::parseCidr($candidate) !== null; + } + + /** + * Check whether an IP address falls inside a CIDR block. + * + * Malformed input or mismatched address families (IPv4 vs IPv6) never match. + */ + private static function cidrContains(string $cidr, string $ip): bool + { + $parsed = self::parseCidr($cidr); + if ($parsed === null) { + return false; + } + + [$network, $prefix] = $parsed; + + $address = @\inet_pton($ip); + if ($address === false || \strlen($address) !== \strlen($network)) { + return false; + } + + $fullBytes = \intdiv($prefix, 8); + if ($fullBytes > 0 && \substr($address, 0, $fullBytes) !== \substr($network, 0, $fullBytes)) { + return false; + } + + $remainingBits = $prefix % 8; + if ($remainingBits === 0) { + return true; + } + + $mask = 0xFF << (8 - $remainingBits) & 0xFF; + + return (\ord($address[$fullBytes]) & $mask) === (\ord($network[$fullBytes]) & $mask); + } + + /** + * Parse a CIDR block into its packed network address and prefix length. + * + * @return array{0: string, 1: int}|null + */ + private static function parseCidr(string $candidate): ?array + { + $separator = \strpos($candidate, '/'); + if ($separator === false) { + return null; + } + + $ip = \substr($candidate, 0, $separator); + $prefixPart = \substr($candidate, $separator + 1); + + if ($prefixPart === '' || !\ctype_digit($prefixPart)) { + return null; + } + + $network = @\inet_pton($ip); + if ($network === false) { + return null; + } + + $prefix = (int) $prefixPart; + $maxPrefix = \strlen($network) * 8; + if ($prefix > $maxPrefix) { + return null; + } + + return [$network, $prefix]; + } +} diff --git a/tests/Types/IpTypeTest.php b/tests/Types/IpTypeTest.php new file mode 100644 index 0000000..85818b8 --- /dev/null +++ b/tests/Types/IpTypeTest.php @@ -0,0 +1,107 @@ +type = new IpType(); + } + + public function testCompareHandlesCidrValues(): void + { + $this->assertTrue($this->type->compare(Condition::TYPE_EQUAL, '10.0.0.1', '10.0.0.0/8')); + $this->assertTrue($this->type->compare(Condition::TYPE_EQUAL, '10.255.255.255', '10.0.0.0/8')); + $this->assertFalse($this->type->compare(Condition::TYPE_EQUAL, '11.0.0.0', '10.0.0.0/8')); + $this->assertFalse($this->type->compare(Condition::TYPE_EQUAL, '9.255.255.255', '10.0.0.0/8')); + } + + public function testCompareHandlesNonOctetAlignedPrefixes(): void + { + $this->assertTrue($this->type->compare(Condition::TYPE_EQUAL, '192.168.31.255', '192.168.16.0/20')); + $this->assertFalse($this->type->compare(Condition::TYPE_EQUAL, '192.168.32.0', '192.168.16.0/20')); + } + + public function testCompareHandlesHostAndCatchAllPrefixes(): void + { + // /32 is an exact host match. + $this->assertTrue($this->type->compare(Condition::TYPE_EQUAL, '203.0.113.10', '203.0.113.10/32')); + $this->assertFalse($this->type->compare(Condition::TYPE_EQUAL, '203.0.113.11', '203.0.113.10/32')); + + // Everything matches /0. + $this->assertTrue($this->type->compare(Condition::TYPE_EQUAL, '203.0.113.10', '0.0.0.0/0')); + + // Host bits set in the CIDR are masked away. + $this->assertTrue($this->type->compare(Condition::TYPE_EQUAL, '10.200.0.1', '10.1.2.3/8')); + } + + public function testCompareHandlesIpv6(): void + { + $this->assertTrue($this->type->compare(Condition::TYPE_EQUAL, '2001:db8::1', '2001:db8::/32')); + $this->assertTrue($this->type->compare(Condition::TYPE_EQUAL, '2001:db8:ffff:ffff:ffff:ffff:ffff:ffff', '2001:db8::/32')); + $this->assertFalse($this->type->compare(Condition::TYPE_EQUAL, '2001:db9::1', '2001:db8::/32')); + + $this->assertTrue($this->type->compare(Condition::TYPE_EQUAL, '::1', '::1/128')); + $this->assertFalse($this->type->compare(Condition::TYPE_EQUAL, '::2', '::1/128')); + + $this->assertTrue($this->type->compare(Condition::TYPE_EQUAL, '2001:db8::1', '::/0')); + } + + public function testCompareRejectsFamilyMismatch(): void + { + $this->assertFalse($this->type->compare(Condition::TYPE_EQUAL, '2001:db8::1', '10.0.0.0/8')); + $this->assertFalse($this->type->compare(Condition::TYPE_EQUAL, '10.0.0.1', '2001:db8::/32')); + } + + public function testCompareFallsBackForPlainIps(): void + { + $this->assertNull($this->type->compare(Condition::TYPE_EQUAL, '10.0.0.1', '10.0.0.1')); + } + + public function testCompareFallsBackForMalformedCidrValues(): void + { + // Not valid CIDR blocks -> not handled, default string equality applies. + $this->assertNull($this->type->compare(Condition::TYPE_EQUAL, '10.0.0.1', '10.0.0.0/33')); + $this->assertNull($this->type->compare(Condition::TYPE_EQUAL, '10.0.0.1', '2001:db8::/129')); + $this->assertNull($this->type->compare(Condition::TYPE_EQUAL, '10.0.0.1', '10.0.0.0/')); + $this->assertNull($this->type->compare(Condition::TYPE_EQUAL, '10.0.0.1', '10.0.0.0/-1')); + $this->assertNull($this->type->compare(Condition::TYPE_EQUAL, '10.0.0.1', '10.0.0.0/8.5')); + $this->assertNull($this->type->compare(Condition::TYPE_EQUAL, '10.0.0.1', 'not-an-ip/8')); + $this->assertNull($this->type->compare(Condition::TYPE_EQUAL, '10.0.0.1', '/8')); + $this->assertNull($this->type->compare(Condition::TYPE_EQUAL, '10.0.0.1', '')); + } + + public function testCompareFallsBackForOtherMethods(): void + { + $this->assertNull($this->type->compare(Condition::TYPE_CONTAINS, '10.0.0.1', '10.0.0.0/8')); + $this->assertNull($this->type->compare(Condition::TYPE_STARTS_WITH, '10.0.0.1', '10.0.0.0/8')); + } + + public function testCompareRejectsNonStringValuesAgainstCidr(): void + { + $this->assertFalse($this->type->compare(Condition::TYPE_EQUAL, null, '10.0.0.0/8')); + $this->assertFalse($this->type->compare(Condition::TYPE_EQUAL, 42, '10.0.0.0/8')); + $this->assertFalse($this->type->compare(Condition::TYPE_EQUAL, 'not-an-ip', '10.0.0.0/8')); + } + + public function testValidateValue(): void + { + $this->assertNull($this->type->validateValue(Condition::TYPE_EQUAL, '203.0.113.10')); + $this->assertNull($this->type->validateValue(Condition::TYPE_EQUAL, '10.0.0.0/8')); + $this->assertNull($this->type->validateValue(Condition::TYPE_NOT_EQUAL, '2001:db8::/32')); + + $this->assertNotNull($this->type->validateValue(Condition::TYPE_EQUAL, '10.0.0.0/33')); + $this->assertNotNull($this->type->validateValue(Condition::TYPE_EQUAL, 'not-an-ip')); + $this->assertNotNull($this->type->validateValue(Condition::TYPE_EQUAL, 42)); + + // Operators outside equality are not value-restricted by this type. + $this->assertNull($this->type->validateValue(Condition::TYPE_IS_NULL, 'anything')); + } +} From 57634381d18794e5a64cd164a88be65d13b700a7 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Jul 2026 16:33:50 +0530 Subject: [PATCH 2/7] feat: thread attribute types through firewall rule matching Firewall holds an attribute-type map, defaulting to ip => IpType so IP conditions match CIDR blocks out of the box, with setAttributeType() for custom registrations. verify() passes the map through Rule::matches() into Condition::matches(), where equality probes the attribute's type before falling back to the existing comparison semantics; notEqual inherits the typed result through negation. Type lookup normalizes attribute names with the same aliasing rules as setAttribute(), so requestIp and IP resolve to the ip type. --- src/Condition.php | 30 ++++++++++++++++++------- src/Firewall.php | 50 ++++++++++++++++++++++++++++++++++++++++- src/Rule.php | 5 +++-- tests/ConditionTest.php | 35 +++++++++++++++++++++++++++++ tests/FirewallTest.php | 43 +++++++++++++++++++++++++++++++++++ 5 files changed, 152 insertions(+), 11 deletions(-) diff --git a/src/Condition.php b/src/Condition.php index 2731c9b..be164bc 100644 --- a/src/Condition.php +++ b/src/Condition.php @@ -341,18 +341,20 @@ public static function or(array $conditions): self * Evaluate the condition against resolved attributes. * * @param array $attributes + * @param array $types typed matching semantics, keyed by normalized attribute name */ - public function matches(array $attributes): bool + public function matches(array $attributes, array $types = []): bool { if ($this->isLogical()) { - return $this->matchesLogical($attributes); + return $this->matchesLogical($attributes, $types); } $value = $this->resolveValue($attributes); + $type = $types === [] ? null : ($types[Firewall::normalizeAttributeName($this->attribute)] ?? null); return match ($this->method) { - self::TYPE_EQUAL => $this->matchesEqual($value), - self::TYPE_NOT_EQUAL => !$this->matchesEqual($value), + self::TYPE_EQUAL => $this->matchesEqual($value, $type), + self::TYPE_NOT_EQUAL => !$this->matchesEqual($value, $type), self::TYPE_LESS_THAN => $this->matchesRelational($value, $this->values[0] ?? null, static fn (int $result): bool => $result < 0), self::TYPE_LESS_THAN_EQUAL => $this->matchesRelational($value, $this->values[0] ?? null, static fn (int $result): bool => $result <= 0), self::TYPE_GREATER_THAN => $this->matchesRelational($value, $this->values[0] ?? null, static fn (int $result): bool => $result > 0), @@ -396,12 +398,13 @@ private function normalizeValues(array $values): array /** * @param array $attributes + * @param array $types */ - private function matchesLogical(array $attributes): bool + private function matchesLogical(array $attributes, array $types): bool { if ($this->method === self::TYPE_AND) { foreach ($this->values as $condition) { - if (!$condition->matches($attributes)) { + if (!$condition->matches($attributes, $types)) { return false; } } @@ -410,7 +413,7 @@ private function matchesLogical(array $attributes): bool } foreach ($this->values as $condition) { - if ($condition->matches($attributes)) { + if ($condition->matches($attributes, $types)) { return true; } } @@ -418,9 +421,20 @@ private function matchesLogical(array $attributes): bool return false; } - private function matchesEqual(mixed $value): bool + private function matchesEqual(mixed $value, ?Types\AttributeType $type = null): bool { foreach ($this->values as $expected) { + // Always probe with equality semantics: notEqual is the negation + // of this method's result, applied by the caller. + $handled = $type?->compare(self::TYPE_EQUAL, $value, $expected); + if ($handled === true) { + return true; + } + + if ($handled === false) { + continue; + } + if (\is_string($expected) && \is_string($value)) { if (\strtolower($expected) === \strtolower($value)) { return true; diff --git a/src/Firewall.php b/src/Firewall.php index 59681d1..a42399b 100644 --- a/src/Firewall.php +++ b/src/Firewall.php @@ -2,6 +2,9 @@ namespace Utopia\WAF; +use Utopia\WAF\Types\AttributeType; +use Utopia\WAF\Types\IpType; + class Firewall { /** @@ -14,8 +17,53 @@ class Firewall */ private array $rules = []; + /** + * Typed matching semantics, keyed by normalized attribute name. + * + * @var array + */ + private array $attributeTypes; + private ?Rule $lastMatchedRule = null; + public function __construct() + { + $this->attributeTypes = [ + 'ip' => new IpType(), + ]; + } + + public function setAttributeType(string $attribute, AttributeType $type): self + { + $this->attributeTypes[self::normalizeAttributeName($attribute)] = $type; + + return $this; + } + + /** + * @return array + */ + public function getAttributeTypes(): array + { + return $this->attributeTypes; + } + + /** + * Normalize an attribute name for type lookup, mirroring the aliasing + * applied by setAttribute() ("requestIp" and "IP" both resolve to "ip"). + */ + public static function normalizeAttributeName(string $name): string + { + if (stripos($name, 'request') === 0) { + $withoutPrefix = substr($name, 7); + if ($withoutPrefix !== false && $withoutPrefix !== '') { + $name = $withoutPrefix; + } + } + + return strtolower($name); + } + public function setAttribute(string $name, mixed $value): self { foreach ($this->attributeAliases($name) as $key) { @@ -92,7 +140,7 @@ public function verify(): bool $this->lastMatchedRule = null; foreach ($this->rules as $rule) { - if (!$rule->matches($this->attributes)) { + if (!$rule->matches($this->attributes, $this->attributeTypes)) { continue; } diff --git a/src/Rule.php b/src/Rule.php index 4cc6370..96762e7 100644 --- a/src/Rule.php +++ b/src/Rule.php @@ -67,11 +67,12 @@ public function addCondition(Condition $condition): self * Evaluate rule conditions against provided attributes. * * @param array $attributes + * @param array $types */ - public function matches(array $attributes): bool + public function matches(array $attributes, array $types = []): bool { foreach ($this->conditions as $condition) { - if (!$condition->matches($attributes)) { + if (!$condition->matches($attributes, $types)) { return false; } } diff --git a/tests/ConditionTest.php b/tests/ConditionTest.php index e6d0dfe..e546607 100644 --- a/tests/ConditionTest.php +++ b/tests/ConditionTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\TestCase; use Utopia\WAF\Condition; use Utopia\WAF\Exception\Condition as ConditionException; +use Utopia\WAF\Types\IpType; class ConditionTest extends TestCase { @@ -231,4 +232,38 @@ public function testParseRejectsInvalidJson(): void Condition::decode('{"method":'); } + + public function testEqualWithAttributeTypeMatchesCidrBlocks(): void + { + $types = ['ip' => new IpType()]; + + $equal = Condition::equal('ip', ['203.0.113.10', '10.0.0.0/8']); + $this->assertTrue($equal->matches(['ip' => '203.0.113.10'], $types)); // plain IP, default equality + $this->assertTrue($equal->matches(['ip' => '10.4.20.9'], $types)); // CIDR containment + $this->assertFalse($equal->matches(['ip' => '11.0.0.1'], $types)); + + // notEqual inherits the typed semantics through negation. + $notEqual = Condition::notEqual('ip', '10.0.0.0/8'); + $this->assertFalse($notEqual->matches(['ip' => '10.4.20.9'], $types)); + $this->assertTrue($notEqual->matches(['ip' => '11.0.0.1'], $types)); + + // Nested logical conditions carry the types down. + $nested = Condition::or([ + Condition::equal('ip', ['10.0.0.0/8']), + Condition::equal('country', ['US']), + ]); + $this->assertTrue($nested->matches(['ip' => '10.1.1.1', 'country' => 'IN'], $types)); + $this->assertFalse($nested->matches(['ip' => '11.1.1.1', 'country' => 'IN'], $types)); + } + + public function testEqualWithoutTypesKeepsPlainStringSemantics(): void + { + // Without a registered type a CIDR value never matches, and + // slash-containing values on other attributes stay untouched. + $equal = Condition::equal('ip', ['10.0.0.0/8']); + $this->assertFalse($equal->matches(['ip' => '10.4.20.9'])); + + $path = Condition::equal('path', ['/v1/health']); + $this->assertTrue($path->matches(['path' => '/v1/health'], ['ip' => new IpType()])); + } } diff --git a/tests/FirewallTest.php b/tests/FirewallTest.php index 5dc166c..6d39e1a 100644 --- a/tests/FirewallTest.php +++ b/tests/FirewallTest.php @@ -106,4 +106,47 @@ public function testRuleIdentifierRoundTrip(): void $this->assertFalse($firewall->verify()); $this->assertSame('rule_abc', $firewall->getLastMatchedRule()?->getId()); } + + public function testIpConditionsMatchCidrBlocksByDefault(): void + { + $deny = (new Deny([ + Condition::equal('ip', ['10.0.0.0/8']), + ]))->setId('rule_cidr'); + + // The requestIP alias resolves to the ip attribute and its default IpType. + $firewall = new Firewall(); + $firewall->setAttribute('requestIP', '10.4.20.9'); + $firewall->addRule($deny); + + $this->assertFalse($firewall->verify()); + $this->assertSame('rule_cidr', $firewall->getLastMatchedRule()?->getId()); + + $miss = new Firewall(); + $miss->setAttribute('requestIP', '11.0.0.1'); + $miss->addRule($deny); + + $miss->verify(); + $this->assertNull($miss->getLastMatchedRule()); + } + + public function testNotEqualIpConditionExcludesCidrBlock(): void + { + $deny = (new Deny([ + Condition::notEqual('ip', '10.0.0.0/8'), + ]))->setId('rule_outside'); + + $inside = new Firewall(); + $inside->setAttribute('ip', '10.4.20.9'); + $inside->addRule($deny); + + $inside->verify(); + $this->assertNull($inside->getLastMatchedRule()); + + $outside = new Firewall(); + $outside->setAttribute('ip', '203.0.113.10'); + $outside->addRule($deny); + + $this->assertFalse($outside->verify()); + $this->assertSame('rule_outside', $outside->getLastMatchedRule()?->getId()); + } } From 7e604c1a69576dec6a9fce4ca21105f7803ad003 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Jul 2026 16:33:58 +0530 Subject: [PATCH 3/7] feat(validator): validate condition values against attribute types Conditions accepts an optional attributeTypes map and rejects leaf condition values the attribute's type deems invalid (e.g. a malformed CIDR block like 10.0.0.0/33 on ip), recursing through nested logical conditions. Without the map, behavior is unchanged. --- src/Validator/Conditions.php | 47 +++++++++++++++++++++++++++++- tests/Validator/ConditionsTest.php | 45 ++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/Validator/Conditions.php b/src/Validator/Conditions.php index f27526b..071e961 100644 --- a/src/Validator/Conditions.php +++ b/src/Validator/Conditions.php @@ -4,12 +4,18 @@ use Utopia\Validator; use Utopia\WAF\Condition; +use Utopia\WAF\Firewall; +use Utopia\WAF\Types\AttributeType; class Conditions extends Validator { + /** + * @param array $attributeTypes typed value validation, keyed by attribute name + */ public function __construct( private int $maxConditions = 100, - private int $maxPayloadLength = 4096 + private int $maxPayloadLength = 4096, + private array $attributeTypes = [] ) { } @@ -83,6 +89,10 @@ private function isValidCondition(array|string $payload, int &$count): bool $method = $payload['method'] ?? ''; $values = $payload['values'] ?? []; + if (!$this->hasValidTypedValues($payload)) { + return false; + } + if (\in_array($method, [Condition::TYPE_AND, Condition::TYPE_OR], true)) { if (!\is_array($values) || \count($values) === 0) { return false; @@ -104,6 +114,41 @@ private function isValidCondition(array|string $payload, int &$count): bool return true; } + /** + * @param array $payload + */ + private function hasValidTypedValues(array $payload): bool + { + if ($this->attributeTypes === []) { + return true; + } + + $method = $payload['method'] ?? ''; + $attribute = $payload['attribute'] ?? ''; + $values = $payload['values'] ?? []; + + if (!\is_string($method) || !\is_string($attribute) || !\is_array($values)) { + return true; // structural validity is enforced by Condition::fromArray() + } + + if (\in_array($method, [Condition::TYPE_AND, Condition::TYPE_OR], true)) { + return true; // nested conditions are validated recursively + } + + $type = $this->attributeTypes[Firewall::normalizeAttributeName($attribute)] ?? null; + if ($type === null) { + return true; + } + + foreach ($values as $value) { + if ($type->validateValue($method, $value) !== null) { + return false; + } + } + + return true; + } + /** * @param array $payload */ diff --git a/tests/Validator/ConditionsTest.php b/tests/Validator/ConditionsTest.php index 6acaeb6..fa22b98 100644 --- a/tests/Validator/ConditionsTest.php +++ b/tests/Validator/ConditionsTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use Utopia\WAF\Condition; +use Utopia\WAF\Types\IpType; use Utopia\WAF\Validator\Conditions; class ConditionsTest extends TestCase @@ -141,4 +142,48 @@ public function testRejectsEmptyLogicalConditions(): void ], ])); } + + public function testTypedValueValidation(): void + { + $validator = new Conditions(attributeTypes: ['ip' => new IpType()]); + + // Plain IPs and CIDR blocks are valid ip values. + $this->assertTrue($validator->isValid([ + Condition::equal('ip', ['203.0.113.10', '10.0.0.0/8'])->toArray(), + ])); + + // Malformed CIDR and non-IP strings are rejected. + $this->assertFalse($validator->isValid([ + Condition::equal('ip', ['10.0.0.0/33'])->toArray(), + ])); + $this->assertFalse($validator->isValid([ + Condition::equal('ip', ['not-an-ip'])->toArray(), + ])); + + // The requestIp alias resolves to the same type. + $this->assertFalse($validator->isValid([ + Condition::equal('requestIp', ['not-an-ip'])->toArray(), + ])); + + // Nested conditions are checked too. + $this->assertFalse($validator->isValid([ + [ + 'method' => Condition::TYPE_OR, + 'values' => [ + Condition::equal('ip', ['bogus'])->toArray(), + Condition::equal('country', ['US'])->toArray(), + ], + ], + ])); + + // Untyped attributes are unaffected. + $this->assertTrue($validator->isValid([ + Condition::equal('path', ['/v1/health'])->toArray(), + ])); + + // Without types, previous behavior is preserved. + $this->assertTrue((new Conditions())->isValid([ + Condition::equal('ip', ['not-an-ip'])->toArray(), + ])); + } } From c2f14aa84362a0c5425121f61528a75abe733f2e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Jul 2026 16:39:13 +0530 Subject: [PATCH 4/7] fix(validator): normalize attribute-type keys at registration Lookup normalizes condition attributes (requestIp/IP -> ip), so a type registered under an aliased spelling was never consulted and malformed typed values slipped through. Normalize map keys in the constructor, mirroring Firewall::setAttributeType(). --- src/Validator/Conditions.php | 10 +++++++++- tests/Validator/ConditionsTest.php | 13 +++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/Validator/Conditions.php b/src/Validator/Conditions.php index 071e961..939ce85 100644 --- a/src/Validator/Conditions.php +++ b/src/Validator/Conditions.php @@ -9,14 +9,22 @@ class Conditions extends Validator { + /** + * @var array + */ + private array $attributeTypes = []; + /** * @param array $attributeTypes typed value validation, keyed by attribute name */ public function __construct( private int $maxConditions = 100, private int $maxPayloadLength = 4096, - private array $attributeTypes = [] + array $attributeTypes = [] ) { + foreach ($attributeTypes as $attribute => $type) { + $this->attributeTypes[Firewall::normalizeAttributeName($attribute)] = $type; + } } public function getDescription(): string diff --git a/tests/Validator/ConditionsTest.php b/tests/Validator/ConditionsTest.php index fa22b98..c5c7a4d 100644 --- a/tests/Validator/ConditionsTest.php +++ b/tests/Validator/ConditionsTest.php @@ -165,6 +165,19 @@ public function testTypedValueValidation(): void Condition::equal('requestIp', ['not-an-ip'])->toArray(), ])); + // Aliased registration keys are normalized too: a type registered + // under requestIp must still validate conditions written as ip. + $aliased = new Conditions(attributeTypes: ['requestIp' => new IpType()]); + $this->assertFalse($aliased->isValid([ + Condition::equal('ip', ['not-an-ip'])->toArray(), + ])); + $this->assertFalse($aliased->isValid([ + Condition::equal('IP', ['10.0.0.0/33'])->toArray(), + ])); + $this->assertTrue($aliased->isValid([ + Condition::equal('ip', ['10.0.0.0/8'])->toArray(), + ])); + // Nested conditions are checked too. $this->assertFalse($validator->isValid([ [ From 67745da9834f6ec0da396bab24bb0e3553c9ce7e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Jul 2026 16:46:23 +0530 Subject: [PATCH 5/7] fix: consult attribute types for all non-logical operators Typed matching was only probed for equality, so a custom type registering semantics for contains, relational, range, or prefix/suffix operators was silently ignored and fell through to the generic string comparisons. Probe the attribute's type in every non-logical operator except isNull/isNotNull: any-of operators (equal, contains) probe per expected value, startsWith/endsWith and relational operators probe their single reference, and between probes once with the full [start, end] pair. Negated operators keep probing their positive counterpart with the negation applied by the engine. IpType is unaffected: it handles equality only and returns null elsewhere. --- src/Condition.php | 91 ++++++++++++++++++++++++------------- src/Types/AttributeType.php | 10 +++- tests/ConditionTest.php | 65 ++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 33 deletions(-) diff --git a/src/Condition.php b/src/Condition.php index be164bc..cc44ec2 100644 --- a/src/Condition.php +++ b/src/Condition.php @@ -4,6 +4,7 @@ use JsonException; use Utopia\WAF\Exception\Condition as ConditionException; +use Utopia\WAF\Types\AttributeType; /** * Condition @@ -355,18 +356,18 @@ public function matches(array $attributes, array $types = []): bool return match ($this->method) { self::TYPE_EQUAL => $this->matchesEqual($value, $type), self::TYPE_NOT_EQUAL => !$this->matchesEqual($value, $type), - self::TYPE_LESS_THAN => $this->matchesRelational($value, $this->values[0] ?? null, static fn (int $result): bool => $result < 0), - self::TYPE_LESS_THAN_EQUAL => $this->matchesRelational($value, $this->values[0] ?? null, static fn (int $result): bool => $result <= 0), - self::TYPE_GREATER_THAN => $this->matchesRelational($value, $this->values[0] ?? null, static fn (int $result): bool => $result > 0), - self::TYPE_GREATER_THAN_EQUAL => $this->matchesRelational($value, $this->values[0] ?? null, static fn (int $result): bool => $result >= 0), - self::TYPE_CONTAINS => $this->matchesContains($value, $this->values), - self::TYPE_NOT_CONTAINS => !$this->matchesContains($value, $this->values), - self::TYPE_BETWEEN => $this->matchesRange($value, true), - self::TYPE_NOT_BETWEEN => !$this->matchesRange($value, true), - self::TYPE_STARTS_WITH => $this->matchesPrefix($value), - self::TYPE_NOT_STARTS_WITH => !$this->matchesPrefix($value), - self::TYPE_ENDS_WITH => $this->matchesSuffix($value), - self::TYPE_NOT_ENDS_WITH => !$this->matchesSuffix($value), + self::TYPE_LESS_THAN => $this->matchesRelational($value, $this->values[0] ?? null, static fn (int $result): bool => $result < 0, $type), + self::TYPE_LESS_THAN_EQUAL => $this->matchesRelational($value, $this->values[0] ?? null, static fn (int $result): bool => $result <= 0, $type), + self::TYPE_GREATER_THAN => $this->matchesRelational($value, $this->values[0] ?? null, static fn (int $result): bool => $result > 0, $type), + self::TYPE_GREATER_THAN_EQUAL => $this->matchesRelational($value, $this->values[0] ?? null, static fn (int $result): bool => $result >= 0, $type), + self::TYPE_CONTAINS => $this->matchesContains($value, $this->values, $type), + self::TYPE_NOT_CONTAINS => !$this->matchesContains($value, $this->values, $type), + self::TYPE_BETWEEN => $this->matchesRange($value, true, $type), + self::TYPE_NOT_BETWEEN => !$this->matchesRange($value, true, $type), + self::TYPE_STARTS_WITH => $this->matchesPrefix($value, $type), + self::TYPE_NOT_STARTS_WITH => !$this->matchesPrefix($value, $type), + self::TYPE_ENDS_WITH => $this->matchesSuffix($value, $type), + self::TYPE_NOT_ENDS_WITH => !$this->matchesSuffix($value, $type), self::TYPE_IS_NULL => $value === null, self::TYPE_IS_NOT_NULL => $value !== null, default => false, @@ -421,7 +422,7 @@ private function matchesLogical(array $attributes, array $types): bool return false; } - private function matchesEqual(mixed $value, ?Types\AttributeType $type = null): bool + private function matchesEqual(mixed $value, ?AttributeType $type = null): bool { foreach ($this->values as $expected) { // Always probe with equality semantics: notEqual is the negation @@ -454,37 +455,48 @@ private function matchesEqual(mixed $value, ?Types\AttributeType $type = null): /** * @param array $needles */ - private function matchesContains(mixed $value, array $needles): bool + private function matchesContains(mixed $value, array $needles, ?AttributeType $type = null): bool { - if (\is_array($value)) { - // Mirror the old array_intersect() semantics (scalars compared as - // strings, so 200 matches '200') while folding case. - foreach ($value as $item) { - foreach ($needles as $needle) { + foreach ($needles as $needle) { + $handled = $type?->compare(self::TYPE_CONTAINS, $value, $needle); + if ($handled === true) { + return true; + } + + if ($handled === false) { + continue; + } + + if (\is_array($value)) { + // Mirror the old array_intersect() semantics (scalars compared + // as strings, so 200 matches '200') while folding case. + foreach ($value as $item) { if (\is_scalar($item) && \is_scalar($needle) && \strtolower((string) $item) === \strtolower((string) $needle)) { return true; } } - } - return false; - } + continue; + } - if (\is_string($value)) { - $haystack = \strtolower($value); - foreach ($needles as $needle) { - if (\is_string($needle) && $needle !== '' && str_contains($haystack, \strtolower($needle))) { - return true; - } + if (\is_string($value) && \is_string($needle) && $needle !== '' + && str_contains(\strtolower($value), \strtolower($needle))) { + return true; } } return false; } - private function matchesRange(mixed $value, bool $inclusive): bool + private function matchesRange(mixed $value, bool $inclusive, ?AttributeType $type = null): bool { + // Ranges are probed once with the full [start, end] pair. + $handled = $type?->compare(self::TYPE_BETWEEN, $value, $this->values); + if ($handled !== null) { + return $handled; + } + if (\count($this->values) < 2) { return false; } @@ -507,10 +519,15 @@ private function matchesRange(mixed $value, bool $inclusive): bool : $startComparison > 0 && $endComparison < 0; } - private function matchesPrefix(mixed $value): bool + private function matchesPrefix(mixed $value, ?AttributeType $type = null): bool { $prefix = $this->values[0] ?? null; + $handled = $type?->compare(self::TYPE_STARTS_WITH, $value, $prefix); + if ($handled !== null) { + return $handled; + } + if (!\is_string($value) || !\is_string($prefix)) { return false; } @@ -518,10 +535,15 @@ private function matchesPrefix(mixed $value): bool return str_starts_with(\strtolower($value), \strtolower($prefix)); } - private function matchesSuffix(mixed $value): bool + private function matchesSuffix(mixed $value, ?AttributeType $type = null): bool { $suffix = $this->values[0] ?? null; + $handled = $type?->compare(self::TYPE_ENDS_WITH, $value, $suffix); + if ($handled !== null) { + return $handled; + } + if (!\is_string($value) || !\is_string($suffix)) { return false; } @@ -589,8 +611,13 @@ private function compare(mixed $left, mixed $right): ?int /** * @param callable(int):bool $verdict */ - private function matchesRelational(mixed $value, mixed $reference, callable $verdict): bool + private function matchesRelational(mixed $value, mixed $reference, callable $verdict, ?AttributeType $type = null): bool { + $handled = $type?->compare($this->method, $value, $reference); + if ($handled !== null) { + return $handled; + } + if ($value === null || $reference === null) { return false; } diff --git a/src/Types/AttributeType.php b/src/Types/AttributeType.php index 24582dc..871009a 100644 --- a/src/Types/AttributeType.php +++ b/src/Types/AttributeType.php @@ -12,12 +12,20 @@ interface AttributeType { /** - * Attempt a typed comparison of one attribute value against one expected value. + * Attempt a typed comparison of an attribute value against an expected value. * * Tri-state contract: * true - handled, the value matches * false - handled, the value definitively does not match (default comparison is skipped) * null - not handled, fall back to the default comparison semantics + * + * Probed for every non-logical operator except isNull/isNotNull. Negated + * operators probe their positive counterpart (notEqual as equal, notContains + * as contains, ...) with the negation applied by the engine, so a type only + * implements the positive semantics. Any-of operators (equal, contains) + * probe once per expected value; startsWith/endsWith and relational + * operators probe with their single reference value; between/notBetween + * probe once with the full [start, end] pair as $expected. */ public function compare(string $method, mixed $value, mixed $expected): ?bool; diff --git a/tests/ConditionTest.php b/tests/ConditionTest.php index e546607..d893439 100644 --- a/tests/ConditionTest.php +++ b/tests/ConditionTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\TestCase; use Utopia\WAF\Condition; use Utopia\WAF\Exception\Condition as ConditionException; +use Utopia\WAF\Types\AttributeType; use Utopia\WAF\Types\IpType; class ConditionTest extends TestCase @@ -256,6 +257,70 @@ public function testEqualWithAttributeTypeMatchesCidrBlocks(): void $this->assertFalse($nested->matches(['ip' => '11.1.1.1', 'country' => 'IN'], $types)); } + public function testAttributeTypeIsProbedForAllOperators(): void + { + // Stub type: 'MATCH' => handled match, 'BLOCK' => handled no-match, + // anything else => null (default semantics). Records every probe. + $type = new class () implements AttributeType { + /** @var array */ + public array $calls = []; + + public function compare(string $method, mixed $value, mixed $expected): ?bool + { + $this->calls[] = [$method, $value, $expected]; + + $needles = \is_array($expected) ? $expected : [$expected]; + + if (\in_array('MATCH', $needles, true)) { + return true; + } + + if (\in_array('BLOCK', $needles, true)) { + return false; + } + + return null; + } + + public function validateValue(string $method, mixed $expected): ?string + { + return null; + } + }; + $types = ['tag' => $type]; + + // contains: typed match wins even where default semantics would miss. + $this->assertTrue(Condition::contains('tag', ['MATCH'])->matches(['tag' => 'nothing-alike'], $types)); + + // contains: a handled "no" suppresses the default substring match. + $this->assertFalse(Condition::contains('tag', ['BLOCK'])->matches(['tag' => 'has BLOCK inside'], $types)); + $this->assertTrue(Condition::notContains('tag', ['BLOCK'])->matches(['tag' => 'has BLOCK inside'], $types)); + + // contains: unhandled needles keep default semantics. + $this->assertTrue(Condition::contains('tag', ['inside'])->matches(['tag' => 'has BLOCK inside'], $types)); + + // startsWith / endsWith and their negations probe the positive operator. + $this->assertTrue(Condition::startsWith('tag', 'MATCH')->matches(['tag' => 'zzz'], $types)); + $this->assertFalse(Condition::notStartsWith('tag', 'MATCH')->matches(['tag' => 'zzz'], $types)); + $this->assertTrue(Condition::endsWith('tag', 'MATCH')->matches(['tag' => 'zzz'], $types)); + + // Relational operators consult the type with their own method. + $this->assertTrue(Condition::lessThan('tag', 'MATCH')->matches(['tag' => 'zzz'], $types)); + $this->assertTrue(Condition::greaterThanEqual('tag', 'MATCH')->matches(['tag' => 'zzz'], $types)); + + // between probes once with the full [start, end] pair. + $type->calls = []; + $this->assertTrue(Condition::between('tag', 'MATCH', 'end')->matches(['tag' => 'zzz'], $types)); + $this->assertSame([[Condition::TYPE_BETWEEN, 'zzz', ['MATCH', 'end']]], $type->calls); + $this->assertFalse(Condition::notBetween('tag', 'MATCH', 'end')->matches(['tag' => 'zzz'], $types)); + + // isNull/isNotNull stay type-agnostic: never probed. + $type->calls = []; + $this->assertTrue(Condition::isNull('tag')->matches(['tag' => null], $types)); + $this->assertTrue(Condition::isNotNull('tag')->matches(['tag' => 'zzz'], $types)); + $this->assertSame([], $type->calls); + } + public function testEqualWithoutTypesKeepsPlainStringSemantics(): void { // Without a registered type a CIDR value never matches, and From 85167e4b0d6741a84e8817c642bab04692862d80 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Jul 2026 16:52:53 +0530 Subject: [PATCH 6/7] refactor: move attribute types to Attribute and Attributes\IP Rename the Types\AttributeType contract to Attribute at the package root and its IP/CIDR implementation from Types\IpType to Attributes\IP, mirroring the Rules\* layout. --- src/{Types/AttributeType.php => Attribute.php} | 6 +++--- src/{Types/IpType.php => Attributes/IP.php} | 7 ++++--- src/Condition.php | 17 ++++++++--------- src/Firewall.php | 11 +++++------ src/Rule.php | 2 +- src/Validator/Conditions.php | 6 +++--- .../IpTypeTest.php => Attributes/IPTest.php} | 10 +++++----- tests/ConditionTest.php | 10 +++++----- tests/FirewallTest.php | 2 +- tests/Validator/ConditionsTest.php | 6 +++--- 10 files changed, 38 insertions(+), 39 deletions(-) rename src/{Types/AttributeType.php => Attribute.php} (95%) rename src/{Types/IpType.php => Attributes/IP.php} (97%) rename tests/{Types/IpTypeTest.php => Attributes/IPTest.php} (96%) diff --git a/src/Types/AttributeType.php b/src/Attribute.php similarity index 95% rename from src/Types/AttributeType.php rename to src/Attribute.php index 871009a..6566242 100644 --- a/src/Types/AttributeType.php +++ b/src/Attribute.php @@ -1,15 +1,15 @@ $attributes - * @param array $types typed matching semantics, keyed by normalized attribute name + * @param array $types typed matching semantics, keyed by normalized attribute name */ public function matches(array $attributes, array $types = []): bool { @@ -399,7 +398,7 @@ private function normalizeValues(array $values): array /** * @param array $attributes - * @param array $types + * @param array $types */ private function matchesLogical(array $attributes, array $types): bool { @@ -422,7 +421,7 @@ private function matchesLogical(array $attributes, array $types): bool return false; } - private function matchesEqual(mixed $value, ?AttributeType $type = null): bool + private function matchesEqual(mixed $value, ?Attribute $type = null): bool { foreach ($this->values as $expected) { // Always probe with equality semantics: notEqual is the negation @@ -455,7 +454,7 @@ private function matchesEqual(mixed $value, ?AttributeType $type = null): bool /** * @param array $needles */ - private function matchesContains(mixed $value, array $needles, ?AttributeType $type = null): bool + private function matchesContains(mixed $value, array $needles, ?Attribute $type = null): bool { foreach ($needles as $needle) { $handled = $type?->compare(self::TYPE_CONTAINS, $value, $needle); @@ -489,7 +488,7 @@ private function matchesContains(mixed $value, array $needles, ?AttributeType $t return false; } - private function matchesRange(mixed $value, bool $inclusive, ?AttributeType $type = null): bool + private function matchesRange(mixed $value, bool $inclusive, ?Attribute $type = null): bool { // Ranges are probed once with the full [start, end] pair. $handled = $type?->compare(self::TYPE_BETWEEN, $value, $this->values); @@ -519,7 +518,7 @@ private function matchesRange(mixed $value, bool $inclusive, ?AttributeType $typ : $startComparison > 0 && $endComparison < 0; } - private function matchesPrefix(mixed $value, ?AttributeType $type = null): bool + private function matchesPrefix(mixed $value, ?Attribute $type = null): bool { $prefix = $this->values[0] ?? null; @@ -535,7 +534,7 @@ private function matchesPrefix(mixed $value, ?AttributeType $type = null): bool return str_starts_with(\strtolower($value), \strtolower($prefix)); } - private function matchesSuffix(mixed $value, ?AttributeType $type = null): bool + private function matchesSuffix(mixed $value, ?Attribute $type = null): bool { $suffix = $this->values[0] ?? null; @@ -611,7 +610,7 @@ private function compare(mixed $left, mixed $right): ?int /** * @param callable(int):bool $verdict */ - private function matchesRelational(mixed $value, mixed $reference, callable $verdict, ?AttributeType $type = null): bool + private function matchesRelational(mixed $value, mixed $reference, callable $verdict, ?Attribute $type = null): bool { $handled = $type?->compare($this->method, $value, $reference); if ($handled !== null) { diff --git a/src/Firewall.php b/src/Firewall.php index a42399b..d09ddc1 100644 --- a/src/Firewall.php +++ b/src/Firewall.php @@ -2,8 +2,7 @@ namespace Utopia\WAF; -use Utopia\WAF\Types\AttributeType; -use Utopia\WAF\Types\IpType; +use Utopia\WAF\Attributes\IP; class Firewall { @@ -20,7 +19,7 @@ class Firewall /** * Typed matching semantics, keyed by normalized attribute name. * - * @var array + * @var array */ private array $attributeTypes; @@ -29,11 +28,11 @@ class Firewall public function __construct() { $this->attributeTypes = [ - 'ip' => new IpType(), + 'ip' => new IP(), ]; } - public function setAttributeType(string $attribute, AttributeType $type): self + public function setAttributeType(string $attribute, Attribute $type): self { $this->attributeTypes[self::normalizeAttributeName($attribute)] = $type; @@ -41,7 +40,7 @@ public function setAttributeType(string $attribute, AttributeType $type): self } /** - * @return array + * @return array */ public function getAttributeTypes(): array { diff --git a/src/Rule.php b/src/Rule.php index 96762e7..16dffe1 100644 --- a/src/Rule.php +++ b/src/Rule.php @@ -67,7 +67,7 @@ public function addCondition(Condition $condition): self * Evaluate rule conditions against provided attributes. * * @param array $attributes - * @param array $types + * @param array $types */ public function matches(array $attributes, array $types = []): bool { diff --git a/src/Validator/Conditions.php b/src/Validator/Conditions.php index 939ce85..639384d 100644 --- a/src/Validator/Conditions.php +++ b/src/Validator/Conditions.php @@ -5,17 +5,17 @@ use Utopia\Validator; use Utopia\WAF\Condition; use Utopia\WAF\Firewall; -use Utopia\WAF\Types\AttributeType; +use Utopia\WAF\Attribute; class Conditions extends Validator { /** - * @var array + * @var array */ private array $attributeTypes = []; /** - * @param array $attributeTypes typed value validation, keyed by attribute name + * @param array $attributeTypes typed value validation, keyed by attribute name */ public function __construct( private int $maxConditions = 100, diff --git a/tests/Types/IpTypeTest.php b/tests/Attributes/IPTest.php similarity index 96% rename from tests/Types/IpTypeTest.php rename to tests/Attributes/IPTest.php index 85818b8..676fac6 100644 --- a/tests/Types/IpTypeTest.php +++ b/tests/Attributes/IPTest.php @@ -1,18 +1,18 @@ type = new IpType(); + $this->type = new IP(); } public function testCompareHandlesCidrValues(): void diff --git a/tests/ConditionTest.php b/tests/ConditionTest.php index d893439..90b7927 100644 --- a/tests/ConditionTest.php +++ b/tests/ConditionTest.php @@ -5,8 +5,8 @@ use PHPUnit\Framework\TestCase; use Utopia\WAF\Condition; use Utopia\WAF\Exception\Condition as ConditionException; -use Utopia\WAF\Types\AttributeType; -use Utopia\WAF\Types\IpType; +use Utopia\WAF\Attribute; +use Utopia\WAF\Attributes\IP; class ConditionTest extends TestCase { @@ -236,7 +236,7 @@ public function testParseRejectsInvalidJson(): void public function testEqualWithAttributeTypeMatchesCidrBlocks(): void { - $types = ['ip' => new IpType()]; + $types = ['ip' => new IP()]; $equal = Condition::equal('ip', ['203.0.113.10', '10.0.0.0/8']); $this->assertTrue($equal->matches(['ip' => '203.0.113.10'], $types)); // plain IP, default equality @@ -261,7 +261,7 @@ public function testAttributeTypeIsProbedForAllOperators(): void { // Stub type: 'MATCH' => handled match, 'BLOCK' => handled no-match, // anything else => null (default semantics). Records every probe. - $type = new class () implements AttributeType { + $type = new class () implements Attribute { /** @var array */ public array $calls = []; @@ -329,6 +329,6 @@ public function testEqualWithoutTypesKeepsPlainStringSemantics(): void $this->assertFalse($equal->matches(['ip' => '10.4.20.9'])); $path = Condition::equal('path', ['/v1/health']); - $this->assertTrue($path->matches(['path' => '/v1/health'], ['ip' => new IpType()])); + $this->assertTrue($path->matches(['path' => '/v1/health'], ['ip' => new IP()])); } } diff --git a/tests/FirewallTest.php b/tests/FirewallTest.php index 6d39e1a..841afb4 100644 --- a/tests/FirewallTest.php +++ b/tests/FirewallTest.php @@ -113,7 +113,7 @@ public function testIpConditionsMatchCidrBlocksByDefault(): void Condition::equal('ip', ['10.0.0.0/8']), ]))->setId('rule_cidr'); - // The requestIP alias resolves to the ip attribute and its default IpType. + // The requestIP alias resolves to the ip attribute and its default IP attribute type. $firewall = new Firewall(); $firewall->setAttribute('requestIP', '10.4.20.9'); $firewall->addRule($deny); diff --git a/tests/Validator/ConditionsTest.php b/tests/Validator/ConditionsTest.php index c5c7a4d..c298deb 100644 --- a/tests/Validator/ConditionsTest.php +++ b/tests/Validator/ConditionsTest.php @@ -4,7 +4,7 @@ use PHPUnit\Framework\TestCase; use Utopia\WAF\Condition; -use Utopia\WAF\Types\IpType; +use Utopia\WAF\Attributes\IP; use Utopia\WAF\Validator\Conditions; class ConditionsTest extends TestCase @@ -145,7 +145,7 @@ public function testRejectsEmptyLogicalConditions(): void public function testTypedValueValidation(): void { - $validator = new Conditions(attributeTypes: ['ip' => new IpType()]); + $validator = new Conditions(attributeTypes: ['ip' => new IP()]); // Plain IPs and CIDR blocks are valid ip values. $this->assertTrue($validator->isValid([ @@ -167,7 +167,7 @@ public function testTypedValueValidation(): void // Aliased registration keys are normalized too: a type registered // under requestIp must still validate conditions written as ip. - $aliased = new Conditions(attributeTypes: ['requestIp' => new IpType()]); + $aliased = new Conditions(attributeTypes: ['requestIp' => new IP()]); $this->assertFalse($aliased->isValid([ Condition::equal('ip', ['not-an-ip'])->toArray(), ])); From ef29c7ff4d0b33579d22276cd9445c3a1d82589d Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 27 Jul 2026 18:18:59 +0530 Subject: [PATCH 7/7] feat(validator): optional allowed-attributes list for conditions Conditions accepts an allowedAttributes option restricting which attribute names rule conditions may reference. Entries ending with "." act as prefixes for nested map lookups (headers., query.); names are normalized with the same aliasing as attribute types, so requestIp and IP resolve to ip. An empty list keeps the previous accept-anything behavior. --- src/Validator/Conditions.php | 65 +++++++++++++++++++++++++++++- tests/Validator/ConditionsTest.php | 56 +++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/Validator/Conditions.php b/src/Validator/Conditions.php index 639384d..f23d83d 100644 --- a/src/Validator/Conditions.php +++ b/src/Validator/Conditions.php @@ -14,17 +14,43 @@ class Conditions extends Validator */ private array $attributeTypes = []; + /** + * @var array + */ + private array $allowedAttributes = []; + + /** + * @var array + */ + private array $allowedPrefixes = []; + /** * @param array $attributeTypes typed value validation, keyed by attribute name + * @param array $allowedAttributes attribute names conditions may reference; entries ending + * with "." are prefixes for nested map lookups (e.g. "headers."). + * Empty means any attribute is accepted. */ public function __construct( private int $maxConditions = 100, private int $maxPayloadLength = 4096, - array $attributeTypes = [] + array $attributeTypes = [], + array $allowedAttributes = [] ) { foreach ($attributeTypes as $attribute => $type) { $this->attributeTypes[Firewall::normalizeAttributeName($attribute)] = $type; } + + foreach ($allowedAttributes as $allowed) { + if (!\is_string($allowed) || $allowed === '') { + continue; + } + + if (\str_ends_with($allowed, '.')) { + $this->allowedPrefixes[] = \strtolower($allowed); + } else { + $this->allowedAttributes[] = Firewall::normalizeAttributeName($allowed); + } + } } public function getDescription(): string @@ -97,6 +123,10 @@ private function isValidCondition(array|string $payload, int &$count): bool $method = $payload['method'] ?? ''; $values = $payload['values'] ?? []; + if (!$this->hasAllowedAttribute($payload)) { + return false; + } + if (!$this->hasValidTypedValues($payload)) { return false; } @@ -122,6 +152,39 @@ private function isValidCondition(array|string $payload, int &$count): bool return true; } + /** + * @param array $payload + */ + private function hasAllowedAttribute(array $payload): bool + { + if ($this->allowedAttributes === [] && $this->allowedPrefixes === []) { + return true; + } + + $method = $payload['method'] ?? ''; + if (\in_array($method, [Condition::TYPE_AND, Condition::TYPE_OR], true)) { + return true; // nested conditions are validated recursively + } + + $attribute = $payload['attribute'] ?? ''; + if (!\is_string($attribute) || $attribute === '') { + return false; + } + + $normalized = Firewall::normalizeAttributeName($attribute); + if (\in_array($normalized, $this->allowedAttributes, true)) { + return true; + } + + foreach ($this->allowedPrefixes as $prefix) { + if (\str_starts_with($normalized, $prefix) && $normalized !== $prefix) { + return true; + } + } + + return false; + } + /** * @param array $payload */ diff --git a/tests/Validator/ConditionsTest.php b/tests/Validator/ConditionsTest.php index c298deb..c5ddfab 100644 --- a/tests/Validator/ConditionsTest.php +++ b/tests/Validator/ConditionsTest.php @@ -199,4 +199,60 @@ public function testTypedValueValidation(): void Condition::equal('ip', ['not-an-ip'])->toArray(), ])); } + + public function testAllowedAttributesValidation(): void + { + $validator = new Conditions(allowedAttributes: ['ip', 'method', 'headers.', 'query.']); + + // Exact names and prefixed map lookups pass. + $this->assertTrue($validator->isValid([ + Condition::equal('ip', ['203.0.113.10'])->toArray(), + Condition::equal('method', ['POST'])->toArray(), + Condition::contains('headers.x-canary', ['1'])->toArray(), + Condition::equal('query.debug', ['true'])->toArray(), + ])); + + // Aliases normalize to the same canonical names. + $this->assertTrue($validator->isValid([ + Condition::equal('requestIp', ['203.0.113.10'])->toArray(), + Condition::equal('METHOD', ['POST'])->toArray(), + ])); + + // Unknown attributes are rejected. + $this->assertFalse($validator->isValid([ + Condition::equal('userId', ['abc'])->toArray(), + ])); + + // A bare prefix without a key is not a valid attribute. + $this->assertFalse($validator->isValid([ + Condition::equal('headers.', ['x'])->toArray(), + ])); + + // Nested logical conditions are checked recursively. + $this->assertFalse($validator->isValid([ + [ + 'method' => Condition::TYPE_OR, + 'values' => [ + Condition::equal('ip', ['203.0.113.10'])->toArray(), + Condition::equal('bogus', ['x'])->toArray(), + ], + ], + ])); + + // Missing attribute on a leaf condition is rejected when restricted. + $this->assertFalse($validator->isValid([ + ['method' => Condition::TYPE_EQUAL, 'values' => ['x']], + ])); + + // Empty allowlist keeps previous behavior: anything goes. + $this->assertTrue((new Conditions())->isValid([ + Condition::equal('anything-at-all', ['x'])->toArray(), + ])); + + // Allowlist registration itself accepts alias spellings. + $aliased = new Conditions(allowedAttributes: ['requestIp']); + $this->assertTrue($aliased->isValid([ + Condition::equal('ip', ['203.0.113.10'])->toArray(), + ])); + } }