diff --git a/README.md b/README.md index 7210b7b..0a908c2 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ $json = $condition->encode(); $parsed = Condition::decode($json); ``` -Available operators mirror the database query builder: `equal`, `notEqual`, `lessThan`, `greaterThan`, `contains`, `between`, `startsWith`, `endsWith`, `isNull`, `and`, `or`, and more. +Available operators mirror the database query builder: `equal`, `notEqual`, `lessThan`, `greaterThan`, `contains`, `between`, `startsWith`, `endsWith`, `inCidr`, `notInCidr`, `isNull`, `and`, `or`, and more. ### Rate Limiting diff --git a/src/Condition.php b/src/Condition.php index 2731c9b..cefdb8c 100644 --- a/src/Condition.php +++ b/src/Condition.php @@ -30,6 +30,10 @@ class Condition public const TYPE_ENDS_WITH = 'endsWith'; public const TYPE_NOT_ENDS_WITH = 'notEndsWith'; + // Network helpers. + public const TYPE_IN_CIDR = 'inCidr'; + public const TYPE_NOT_IN_CIDR = 'notInCidr'; + // Null helpers. public const TYPE_IS_NULL = 'isNull'; public const TYPE_IS_NOT_NULL = 'isNotNull'; @@ -61,6 +65,8 @@ class Condition self::TYPE_NOT_STARTS_WITH, self::TYPE_ENDS_WITH, self::TYPE_NOT_ENDS_WITH, + self::TYPE_IN_CIDR, + self::TYPE_NOT_IN_CIDR, self::TYPE_IS_NULL, self::TYPE_IS_NOT_NULL, self::TYPE_AND, @@ -311,6 +317,22 @@ public static function notEndsWith(string $attribute, string $value): self return new self(self::TYPE_NOT_ENDS_WITH, $attribute, [$value]); } + /** + * @param array $values + */ + public static function inCidr(string $attribute, array $values): self + { + return new self(self::TYPE_IN_CIDR, $attribute, $values); + } + + /** + * @param array $values + */ + public static function notInCidr(string $attribute, array $values): self + { + return new self(self::TYPE_NOT_IN_CIDR, $attribute, $values); + } + public static function isNull(string $attribute): self { return new self(self::TYPE_IS_NULL, $attribute); @@ -365,6 +387,8 @@ public function matches(array $attributes): bool 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_IN_CIDR => $this->matchesInCidr($value), + self::TYPE_NOT_IN_CIDR => $this->matchesNotInCidr($value), self::TYPE_IS_NULL => $value === null, self::TYPE_IS_NOT_NULL => $value !== null, default => false, @@ -469,6 +493,165 @@ private function matchesContains(mixed $value, array $needles): bool return false; } + /** + * True when the given string is a valid CIDR block or bare IP address. + * A bare address is treated as a full-length host route (`/32` for IPv4, + * `/128` for IPv6). + */ + public static function isCidr(string $value): bool + { + return self::parseCidr($value) !== null; + } + + private function matchesInCidr(mixed $value): bool + { + $candidate = self::packAddress($value); + + if ($candidate === null) { + return false; + } + + foreach ($this->parsedCidrValues() as $parsed) { + if (self::cidrContains($parsed, $candidate)) { + return true; + } + } + + return false; + } + + /** + * A list without a single usable CIDR entry matches nothing, so an empty + * or fully malformed notInCidr never degrades into match-everything. + */ + private function matchesNotInCidr(mixed $value): bool + { + $candidate = self::packAddress($value); + + if ($candidate === null) { + return false; + } + + $blocks = $this->parsedCidrValues(); + + if ($blocks === []) { + return false; + } + + foreach ($blocks as $parsed) { + if (self::cidrContains($parsed, $candidate)) { + return false; + } + } + + return true; + } + + /** + * @return array + */ + private function parsedCidrValues(): array + { + $parsed = []; + + foreach ($this->values as $cidr) { + if (!\is_string($cidr)) { + continue; + } + + $block = self::parseCidr($cidr); + + if ($block !== null) { + $parsed[] = $block; + } + } + + return $parsed; + } + + private static function packAddress(mixed $value): ?string + { + if (!\is_string($value)) { + return null; + } + + $packed = @\inet_pton($value); + + return $packed === false ? null : $packed; + } + + /** + * Parse a CIDR block into its packed network address and prefix length. + * + * @return array{string, int}|null + */ + private static function parseCidr(string $cidr): ?array + { + if (\str_contains($cidr, '/')) { + [$network, $prefixText] = \explode('/', $cidr, 2); + + if (!\ctype_digit($prefixText)) { + return null; + } + + $prefix = (int) $prefixText; + } else { + $network = $cidr; + $prefix = null; + } + + $networkPacked = self::packAddress($network); + + if ($networkPacked === null) { + return null; + } + + $bits = \strlen($networkPacked) * 8; + $prefix ??= $bits; + + if ($prefix > $bits) { + return null; + } + + return [$networkPacked, $prefix]; + } + + /** + * Bytewise prefix comparison of a packed candidate address against a + * parsed CIDR block. Addresses of different families (4 vs 16 bytes) + * never match. + * + * @param array{string, int} $parsed + */ + private static function cidrContains(array $parsed, string $candidate): bool + { + [$network, $prefix] = $parsed; + + if (\strlen($network) !== \strlen($candidate)) { + return false; + } + + if ($prefix === 0) { + return true; + } + + $fullBytes = \intdiv($prefix, 8); + + if ($fullBytes > 0 && \substr($candidate, 0, $fullBytes) !== \substr($network, 0, $fullBytes)) { + return false; + } + + $remainingBits = $prefix % 8; + + if ($remainingBits === 0) { + return true; + } + + $mask = 0xFF << (8 - $remainingBits) & 0xFF; + + return (\ord($candidate[$fullBytes]) & $mask) === (\ord($network[$fullBytes]) & $mask); + } + private function matchesRange(mixed $value, bool $inclusive): bool { if (\count($this->values) < 2) { diff --git a/src/Validator/Conditions.php b/src/Validator/Conditions.php index f27526b..1c91b0b 100644 --- a/src/Validator/Conditions.php +++ b/src/Validator/Conditions.php @@ -95,6 +95,18 @@ private function isValidCondition(array|string $payload, int &$count): bool } } + if (\in_array($method, [Condition::TYPE_IN_CIDR, Condition::TYPE_NOT_IN_CIDR], true)) { + if (!\is_array($values) || \count($values) === 0) { + return false; + } + + foreach ($values as $value) { + if (!\is_string($value) || !Condition::isCidr($value)) { + return false; + } + } + } + try { Condition::fromArray($payload); } catch (\Throwable) { diff --git a/tests/ConditionTest.php b/tests/ConditionTest.php index e6d0dfe..9896951 100644 --- a/tests/ConditionTest.php +++ b/tests/ConditionTest.php @@ -98,6 +98,133 @@ public function testStartsAndEndsOperators(): void $this->assertFalse($notEndsWith->matches(['path' => '/index.php'])); } + public function testInCidrIpv4Membership(): void + { + $inCidr = Condition::inCidr('ip', ['203.0.113.0/24']); + + $this->assertTrue($inCidr->matches(['ip' => '203.0.113.5'])); + $this->assertFalse($inCidr->matches(['ip' => '203.0.114.5'])); + } + + public function testInCidrIpv6Membership(): void + { + $inCidr = Condition::inCidr('ip', ['2001:db8::/32']); + + $this->assertTrue($inCidr->matches(['ip' => '2001:db8::1'])); + $this->assertFalse($inCidr->matches(['ip' => '2001:db9::1'])); + } + + public function testInCidrMatchesAnyBlockInList(): void + { + $inCidr = Condition::inCidr('ip', ['10.0.0.0/8', '203.0.113.0/24']); + + $this->assertTrue($inCidr->matches(['ip' => '10.5.5.5'])); + $this->assertTrue($inCidr->matches(['ip' => '203.0.113.200'])); + $this->assertFalse($inCidr->matches(['ip' => '192.168.1.1'])); + } + + public function testInCidrBareAddressIsHostRoute(): void + { + $ipv4 = Condition::inCidr('ip', ['203.0.113.5']); + $ipv6 = Condition::inCidr('ip', ['2001:db8::1']); + + $this->assertTrue($ipv4->matches(['ip' => '203.0.113.5'])); + $this->assertFalse($ipv4->matches(['ip' => '203.0.113.6'])); + + $this->assertTrue($ipv6->matches(['ip' => '2001:db8::1'])); + $this->assertFalse($ipv6->matches(['ip' => '2001:db8::2'])); + } + + public function testInCidrPrefixBoundaries(): void + { + $zero = Condition::inCidr('ip', ['0.0.0.0/0']); + $zeroV6 = Condition::inCidr('ip', ['::/0']); + $nonByteAligned = Condition::inCidr('ip', ['192.168.1.64/26']); + + $this->assertTrue($zero->matches(['ip' => '8.8.8.8'])); + $this->assertTrue($zeroV6->matches(['ip' => '2001:db8::1'])); + + $this->assertTrue($nonByteAligned->matches(['ip' => '192.168.1.100'])); + $this->assertFalse($nonByteAligned->matches(['ip' => '192.168.1.200'])); + } + + public function testInCidrNeverMatchesAcrossAddressFamilies(): void + { + $ipv6Block = Condition::inCidr('ip', ['2001:db8::/32']); + $ipv4Block = Condition::inCidr('ip', ['203.0.113.0/24']); + + $this->assertFalse($ipv6Block->matches(['ip' => '203.0.113.5'])); + $this->assertFalse($ipv4Block->matches(['ip' => '2001:db8::1'])); + + // /0 in one family still never matches the other. + $this->assertFalse(Condition::inCidr('ip', ['0.0.0.0/0'])->matches(['ip' => '2001:db8::1'])); + $this->assertFalse(Condition::inCidr('ip', ['::/0'])->matches(['ip' => '8.8.8.8'])); + } + + public function testMalformedCidrEntriesNeverContainAnAddress(): void + { + $this->assertFalse(Condition::inCidr('ip', ['garbage'])->matches(['ip' => '203.0.113.5'])); + $this->assertFalse(Condition::inCidr('ip', ['203.0.113.0/999'])->matches(['ip' => '203.0.113.5'])); + $this->assertFalse(Condition::inCidr('ip', ['203.0.113.0/-1'])->matches(['ip' => '203.0.113.5'])); + $this->assertFalse(Condition::inCidr('ip', [])->matches(['ip' => '203.0.113.5'])); + + // A malformed entry is skipped without poisoning valid siblings. + $this->assertTrue(Condition::inCidr('ip', ['garbage', '203.0.113.0/24'])->matches(['ip' => '203.0.113.5'])); + $this->assertFalse(Condition::notInCidr('ip', ['garbage', '203.0.113.0/24'])->matches(['ip' => '203.0.113.5'])); + $this->assertTrue(Condition::notInCidr('ip', ['garbage', '203.0.113.0/24'])->matches(['ip' => '198.51.100.5'])); + } + + public function testCidrOperatorsWithoutUsableEntriesMatchNothing(): void + { + $address = ['ip' => '203.0.113.5']; + + $this->assertFalse(Condition::inCidr('ip', [])->matches($address)); + $this->assertFalse(Condition::notInCidr('ip', [])->matches($address)); + + $this->assertFalse(Condition::inCidr('ip', ['garbage', '203.0.113.0/999'])->matches($address)); + $this->assertFalse(Condition::notInCidr('ip', ['garbage', '203.0.113.0/999'])->matches($address)); + } + + public function testMalformedAttributeAddressFailsClosedForBothCidrOperators(): void + { + $inCidr = Condition::inCidr('ip', ['203.0.113.0/24']); + $notInCidr = Condition::notInCidr('ip', ['203.0.113.0/24']); + + // An unparseable address is not provably inside nor provably outside, + // so neither operator matches. + foreach (['not-an-ip', '', null, ['203.0.113.5'], 42] as $malformed) { + $this->assertFalse($inCidr->matches(['ip' => $malformed])); + $this->assertFalse($notInCidr->matches(['ip' => $malformed])); + } + + $this->assertFalse($inCidr->matches([])); + $this->assertFalse($notInCidr->matches([])); + } + + public function testNotInCidrInvertsMembership(): void + { + $notInCidr = Condition::notInCidr('ip', ['10.0.0.0/8', '2001:db8::/32']); + + $this->assertFalse($notInCidr->matches(['ip' => '10.5.5.5'])); + $this->assertFalse($notInCidr->matches(['ip' => '2001:db8::1'])); + $this->assertTrue($notInCidr->matches(['ip' => '192.168.1.1'])); + $this->assertTrue($notInCidr->matches(['ip' => '2001:db9::1'])); + } + + public function testCidrConditionSerializationRoundTrip(): void + { + $condition = Condition::or([ + Condition::inCidr('ip', ['203.0.113.0/24']), + Condition::notInCidr('ip', ['0.0.0.0/0']), + ]); + + $parsed = Condition::decode($condition->encode()); + + $this->assertTrue($parsed->matches(['ip' => '203.0.113.5'])); + $this->assertTrue($parsed->matches(['ip' => '2001:db8::1'])); + $this->assertFalse($parsed->matches(['ip' => '192.168.1.1'])); + } + public function testNullOperatorsAndAttributeResolution(): void { $isNull = Condition::isNull('payload.signature'); diff --git a/tests/FirewallTest.php b/tests/FirewallTest.php index 5dc166c..dca777a 100644 --- a/tests/FirewallTest.php +++ b/tests/FirewallTest.php @@ -40,6 +40,26 @@ public function testVerifyUsesPopulatedRequestAttributesAndExposesMatchedRule(): $this->assertNull($firewall->getLastMatchedRule()); } + public function testVerifyDeniesRequestsInsideCidrBlock(): void + { + $firewall = new Firewall(); + $firewall->setAttribute('requestIP', '203.0.113.5'); + + $deny = new Deny([ + Condition::inCidr('ip', ['203.0.113.0/24', '2001:db8::/32']), + ]); + + $firewall->addRule($deny); + + $this->assertFalse($firewall->verify()); + $this->assertSame($deny, $firewall->getLastMatchedRule()); + + $firewall->setAttribute('requestIP', '198.51.100.5'); + + $this->assertFalse($firewall->verify(), 'No rule should match an address outside the block'); + $this->assertNull($firewall->getLastMatchedRule()); + } + public function testRuleOrder(): void { $firewall = new Firewall(); diff --git a/tests/Validator/ConditionsTest.php b/tests/Validator/ConditionsTest.php index 6acaeb6..c7143b8 100644 --- a/tests/Validator/ConditionsTest.php +++ b/tests/Validator/ConditionsTest.php @@ -130,6 +130,50 @@ public function testRejectsLongConditionStrings(): void ])); } + public function testAcceptsCidrConditions(): void + { + $validator = new Conditions(); + + $this->assertTrue($validator->isValid([ + Condition::inCidr('ip', ['10.0.0.0/8', '203.0.113.5', '2001:db8::/32', '2001:db8::1'])->toArray(), + Condition::notInCidr('ip', ['0.0.0.0/0'])->toArray(), + ])); + } + + public function testRejectsEmptyCidrConditions(): void + { + $validator = new Conditions(); + + $this->assertFalse($validator->isValid([ + Condition::inCidr('ip', [])->toArray(), + ])); + } + + public function testRejectsMalformedCidrValues(): void + { + $validator = new Conditions(); + + $this->assertFalse($validator->isValid([ + Condition::inCidr('ip', ['garbage'])->toArray(), + ])); + + $this->assertFalse($validator->isValid([ + Condition::inCidr('ip', ['203.0.113.0/999'])->toArray(), + ])); + + $this->assertFalse($validator->isValid([ + Condition::notInCidr('ip', ['203.0.113.0/24', 'garbage'])->toArray(), + ])); + + $this->assertFalse($validator->isValid([ + [ + 'method' => Condition::TYPE_NOT_IN_CIDR, + 'attribute' => 'ip', + 'values' => [42], + ], + ])); + } + public function testRejectsEmptyLogicalConditions(): void { $validator = new Conditions();