diff --git a/src/Attribute.php b/src/Attribute.php new file mode 100644 index 0000000..6566242 --- /dev/null +++ b/src/Attribute.php @@ -0,0 +1,38 @@ +/"). + */ + 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/src/Condition.php b/src/Condition.php index 2731c9b..396866f 100644 --- a/src/Condition.php +++ b/src/Condition.php @@ -341,30 +341,32 @@ 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_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_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, $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, @@ -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, ?Attribute $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; @@ -440,37 +454,48 @@ private function matchesEqual(mixed $value): bool /** * @param array $needles */ - private function matchesContains(mixed $value, array $needles): bool + private function matchesContains(mixed $value, array $needles, ?Attribute $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, ?Attribute $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; } @@ -493,10 +518,15 @@ private function matchesRange(mixed $value, bool $inclusive): bool : $startComparison > 0 && $endComparison < 0; } - private function matchesPrefix(mixed $value): bool + private function matchesPrefix(mixed $value, ?Attribute $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; } @@ -504,10 +534,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, ?Attribute $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; } @@ -575,8 +610,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, ?Attribute $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/Firewall.php b/src/Firewall.php index 59681d1..d09ddc1 100644 --- a/src/Firewall.php +++ b/src/Firewall.php @@ -2,6 +2,8 @@ namespace Utopia\WAF; +use Utopia\WAF\Attributes\IP; + class Firewall { /** @@ -14,8 +16,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 IP(), + ]; + } + + public function setAttributeType(string $attribute, Attribute $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 +139,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..16dffe1 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/src/Validator/Conditions.php b/src/Validator/Conditions.php index f27526b..f23d83d 100644 --- a/src/Validator/Conditions.php +++ b/src/Validator/Conditions.php @@ -4,13 +4,53 @@ use Utopia\Validator; use Utopia\WAF\Condition; +use Utopia\WAF\Firewall; +use Utopia\WAF\Attribute; class Conditions extends Validator { + /** + * @var array + */ + 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 + private int $maxPayloadLength = 4096, + 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 @@ -83,6 +123,14 @@ 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; + } + if (\in_array($method, [Condition::TYPE_AND, Condition::TYPE_OR], true)) { if (!\is_array($values) || \count($values) === 0) { return false; @@ -104,6 +152,74 @@ 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 + */ + 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/Attributes/IPTest.php b/tests/Attributes/IPTest.php new file mode 100644 index 0000000..676fac6 --- /dev/null +++ b/tests/Attributes/IPTest.php @@ -0,0 +1,107 @@ +type = new IP(); + } + + 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')); + } +} diff --git a/tests/ConditionTest.php b/tests/ConditionTest.php index e6d0dfe..90b7927 100644 --- a/tests/ConditionTest.php +++ b/tests/ConditionTest.php @@ -5,6 +5,8 @@ use PHPUnit\Framework\TestCase; use Utopia\WAF\Condition; use Utopia\WAF\Exception\Condition as ConditionException; +use Utopia\WAF\Attribute; +use Utopia\WAF\Attributes\IP; class ConditionTest extends TestCase { @@ -231,4 +233,102 @@ public function testParseRejectsInvalidJson(): void Condition::decode('{"method":'); } + + public function testEqualWithAttributeTypeMatchesCidrBlocks(): void + { + $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 + $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 testAttributeTypeIsProbedForAllOperators(): void + { + // Stub type: 'MATCH' => handled match, 'BLOCK' => handled no-match, + // anything else => null (default semantics). Records every probe. + $type = new class () implements Attribute { + /** @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 + // 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 IP()])); + } } diff --git a/tests/FirewallTest.php b/tests/FirewallTest.php index 5dc166c..841afb4 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 IP attribute type. + $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()); + } } diff --git a/tests/Validator/ConditionsTest.php b/tests/Validator/ConditionsTest.php index 6acaeb6..c5ddfab 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\Attributes\IP; use Utopia\WAF\Validator\Conditions; class ConditionsTest extends TestCase @@ -141,4 +142,117 @@ public function testRejectsEmptyLogicalConditions(): void ], ])); } + + public function testTypedValueValidation(): void + { + $validator = new Conditions(attributeTypes: ['ip' => new IP()]); + + // 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(), + ])); + + // Aliased registration keys are normalized too: a type registered + // under requestIp must still validate conditions written as ip. + $aliased = new Conditions(attributeTypes: ['requestIp' => new IP()]); + $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([ + [ + '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(), + ])); + } + + 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(), + ])); + } }