Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
183 changes: 183 additions & 0 deletions src/Condition.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<string> $values
*/
public static function inCidr(string $attribute, array $values): self
{
return new self(self::TYPE_IN_CIDR, $attribute, $values);
}

/**
* @param array<string> $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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

$blocks = $this->parsedCidrValues();

if ($blocks === []) {
return false;
}

foreach ($blocks as $parsed) {
if (self::cidrContains($parsed, $candidate)) {
return false;
}
}

return true;
}

/**
* @return array<array{string, int}>
*/
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) {
Expand Down
12 changes: 12 additions & 0 deletions src/Validator/Conditions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
127 changes: 127 additions & 0 deletions tests/ConditionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading