From 6d22c77a1d3954f349c614abfb3d96d0d7fd7688 Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 15 Oct 2026 15:26:59 +0200 Subject: [PATCH] fix(client-ip): align client identity resolution with the reference The forwarded chain follows the reference rules (ports and brackets stripped, empty entries kept, right to left walk when the depth over-counts hops, metacharacter and invalid entries rejected), the unix token is accepted by trusted_proxies, an untrusted peer sending X-Forwarded-For is reported as a spoof attempt, trusted proxy matching follows the reference raw-address rules, zoned addresses parse and render like ipaddress, and list matching compares canonical forms. Adds the generated reference expectation tables as a test runner. --- .github/coverage-unreachable.php | 23 +- .github/workflows/ci.yml | 5 +- Makefile | 2 +- bin/test_client_ip.php | 252 ++++++ bin/test_state.php | 2 +- src/Config/SecurityConfig.php | 15 +- src/Events/EventBus.php | 33 + src/Ip/CanonicalIp.php | 177 +++- src/Pipeline/BlockEvents.php | 5 +- src/Pipeline/CheckFactory.php | 2 +- src/Pipeline/Checks/EmergencyModeCheck.php | 22 +- src/Pipeline/Checks/IpSecurityCheck.php | 25 +- src/Pipeline/Checks/RouteConfigCheck.php | 6 +- src/Request/ClientIpResolver.php | 290 ++++++- tests/data/client_ip_cases.json | 898 +++++++++++++++++++++ tests/data/client_ip_lists.json | 786 ++++++++++++++++++ 16 files changed, 2443 insertions(+), 100 deletions(-) create mode 100644 bin/test_client_ip.php create mode 100644 tests/data/client_ip_cases.json create mode 100644 tests/data/client_ip_lists.json diff --git a/.github/coverage-unreachable.php b/.github/coverage-unreachable.php index c2ff7bf..7ed1be7 100644 --- a/.github/coverage-unreachable.php +++ b/.github/coverage-unreachable.php @@ -161,11 +161,13 @@ 104, ], - // canonicalNetwork re-runs inet_pton on an address canonicalText already - // validated with FILTER_VALIDATE_IP; canonicalText's own inet_pton - // follows a FILTER_VALIDATE_IP pass too, so neither can fail. + // canonicalNetwork re-runs inet_pton on an address strOf already parsed + // (zone-free, rejected above otherwise); parseRaw's and canonicalText's + // inet_pton follow a FILTER_VALIDATE_IP IPv6 pass on the same text (a + // zone is split off before the filter), so none of them can fail or + // return a length other than 16. 'src/Ip/CanonicalIp.php' => [ - 56, 108, + 154, 219, 248, ], // The private constructor is never invoked: LogActivity is used @@ -187,12 +189,13 @@ 225, 231, 243, 245, ], - // The 'unix' trusted-proxy branch requires 'unix' inside - // trustedProxies, but SecurityConfig::validateIpCidrList rejects that - // entry at construction (verified: "trusted_proxies: invalid IP/CIDR - // entry 'unix'"). + // resolveRightToLeft is entered only when at least one entry to the + // right of the depth-selected one is not a trusted proxy, so the walk + // from the right always meets an untrusted entry and returns inside the + // loop; the trailing return (the reference's final `return None`) is + // dead. 'src/Request/ClientIpResolver.php' => [ - 24, 25, 26, 27, 28, 29, + 212, ], // The default loopback transport. The handler suites drive the @@ -255,7 +258,7 @@ // as the BehaviorRule entry above). The suites assert the boundary // TypeError instead (test_json_logging.php). 'src/Config/SecurityConfig.php' => [ - 1162, 1186, + 1171, 1195, ], // 219: the non-array/non-scalar json_decode guard - json_decode can diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af41a02..3cb08d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -172,6 +172,9 @@ jobs: - name: test_dynamic_rules run: php bin/test_dynamic_rules.php + - name: test_client_ip + run: php bin/test_client_ip.php + conformance-pipeline: name: Conformance pipeline (php ${{ matrix.php }}) runs-on: ubuntu-latest @@ -249,7 +252,7 @@ jobs: run: | mkdir -p build php .github/coverage-runner.php --collect bin/test_state.php build/coverage-test_state.php --integration - for suite in test_parity_seam test_ratelimit test_geo_rate_limits test_pipeline test_m3b test_m4 test_behavior test_m3c conformance conformance_safety conformance_events conformance_redis test_nfkc test_binary_noise_gate test_recon_context_gate test_recon_raw_view_scan test_body_form_scan test_json_walk test_exempt_ips test_excluded_headers test_cors test_geo_country test_geo_lifecycle test_event_bus test_security_headers test_large_body test_support test_redos_gates test_performance_monitor test_enrichment test_composite_handlers test_json_logging test_pattern_registry test_config_fields test_events_parity test_decorators test_scan_time_budget test_dynamic_rules; do + for suite in test_parity_seam test_ratelimit test_geo_rate_limits test_pipeline test_m3b test_m4 test_behavior test_m3c conformance conformance_safety conformance_events conformance_redis test_nfkc test_binary_noise_gate test_recon_context_gate test_recon_raw_view_scan test_body_form_scan test_json_walk test_exempt_ips test_excluded_headers test_cors test_geo_country test_geo_lifecycle test_event_bus test_security_headers test_large_body test_support test_redos_gates test_performance_monitor test_enrichment test_composite_handlers test_json_logging test_pattern_registry test_config_fields test_events_parity test_decorators test_scan_time_budget test_dynamic_rules test_client_ip; do php .github/coverage-runner.php --collect "bin/${suite}.php" "build/coverage-${suite}.php" done diff --git a/Makefile b/Makefile index dd18a08..bad64b9 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ REDIS_HOST ?= host.docker.internal # Runners mirrored from CI (.github/workflows/ci.yml). Redis-backed # runners are listed in REDIS_RUNNERS and get REDIS_HOST passed through. REDIS_RUNNERS = bin/test_ratelimit.php bin/test_geo_rate_limits.php bin/test_pipeline.php bin/test_m3b.php bin/test_m4.php bin/test_behavior.php bin/conformance_redis.php -RUNNERS = bin/test_state.php $(REDIS_RUNNERS) bin/test_m3c.php bin/conformance.php bin/conformance_pipeline.php bin/conformance_safety.php bin/conformance_events.php bin/test_nfkc.php bin/test_binary_noise_gate.php bin/test_recon_context_gate.php bin/test_recon_raw_view_scan.php bin/test_body_form_scan.php bin/test_json_walk.php bin/test_exempt_ips.php bin/test_excluded_headers.php bin/test_cors.php bin/test_geo_country.php bin/test_security_headers.php bin/test_behavior.php bin/test_large_body.php bin/test_redos_gates.php bin/test_performance_monitor.php bin/test_geo_lifecycle.php bin/test_event_bus.php bin/test_decorators.php bin/test_scan_time_budget.php bin/test_dynamic_rules.php +RUNNERS = bin/test_state.php $(REDIS_RUNNERS) bin/test_m3c.php bin/conformance.php bin/conformance_pipeline.php bin/conformance_safety.php bin/conformance_events.php bin/test_nfkc.php bin/test_binary_noise_gate.php bin/test_recon_context_gate.php bin/test_recon_raw_view_scan.php bin/test_body_form_scan.php bin/test_json_walk.php bin/test_exempt_ips.php bin/test_excluded_headers.php bin/test_cors.php bin/test_geo_country.php bin/test_security_headers.php bin/test_behavior.php bin/test_large_body.php bin/test_redos_gates.php bin/test_performance_monitor.php bin/test_geo_lifecycle.php bin/test_event_bus.php bin/test_decorators.php bin/test_scan_time_budget.php bin/test_dynamic_rules.php bin/test_client_ip.php .PHONY: install test lint bump-version clean diff --git a/bin/test_client_ip.php b/bin/test_client_ip.php new file mode 100644 index 0000000..7e405aa --- /dev/null +++ b/bin/test_client_ip.php @@ -0,0 +1,252 @@ +section = $name; + echo "=== {$name} ===\n"; + } + + public function ok(bool $condition, string $label): void + { + if ($condition) { + $this->passed++; + } else { + $this->failed++; + echo " FAIL {$this->section} :: {$label}\n"; + } + } + + public function same(mixed $expected, mixed $actual, string $label): void + { + $ok = $expected === $actual; + if (!$ok) { + echo ' expected: ' . var_export($expected, true) . "\n actual: " . var_export($actual, true) . "\n"; + } + $this->ok($ok, $label); + } + + public function done(string $name): int + { + echo "\n{$name}: {$this->passed} passed, {$this->failed} failed\n"; + + return $this->failed === 0 ? 0 : 1; + } +} + +$t = new T(); + +/** @return list> */ +function loadTable(string $name): array +{ + $rows = json_decode((string) file_get_contents(__DIR__ . '/../tests/data/' . $name), true, flags: JSON_THROW_ON_ERROR); + assert(is_array($rows)); + + return $rows; +} + +function makeRequest(?string $host, ?string $xff): SimpleGuardRequest +{ + return new SimpleGuardRequest( + urlPath: '/api', + clientHost: $host === '' ? null : $host, + headers: $xff === null ? [] : ['X-Forwarded-For' => $xff] + ); +} + +$t->section('extract_client_ip matches the reference over its own outputs'); +$cases = loadTable('client_ip_cases.json'); +$t->ok(count($cases) >= 85, 'at least 85 reference rows'); +foreach ($cases as $i => $row) { + ClientIpResolver::resetWarnings(); + $label = sprintf('row %d host=%s xff=%s tp=%s depth=%d', $i, var_export($row['host'], true), var_export($row['xff'], true), json_encode($row['tp']), $row['depth']); + try { + $config = new SecurityConfig(enableRedis: false, trustedProxies: $row['tp'], trustedProxyDepth: $row['depth']); + $got = ClientIpResolver::extract(makeRequest($row['host'], $row['xff']), $config); + } catch (\InvalidArgumentException) { + // A config that cannot be built (a malformed trusted_proxies entry): + // run the resolver steps without the config, the reference + // extract_client_ip body. + $request = makeRequest($row['host'], $row['xff']); + $host = $request->clientHost(); + $canonical = CanonicalIp::canonicalize((string) $host); + $got = ClientIpResolver::isTrustedProxy((string) $host, $row['tp']) + ? ClientIpResolver::resolveForwardedChain($canonical, $row['xff'], $row['depth'], $row['tp']) + : $canonical; + } + $t->same($row['expected'], $got, $label); +} + +$t->section('the engine stores the same identity on request.state.client_ip'); +foreach ($cases as $i => $row) { + ClientIpResolver::resetWarnings(); + try { + $config = new SecurityConfig(enableRedis: false, trustedProxies: $row['tp'], trustedProxyDepth: $row['depth'], failSecure: false); + } catch (\InvalidArgumentException) { + continue; + } + $request = makeRequest($row['host'], $row['xff']); + (new GuardEngine($config))->execute($request); + $t->same($row['expected'], $request->state()->clientIp, "row {$i} state.client_ip"); +} + +$t->section('IP list matching matches the reference _ip_in_list'); +foreach (loadTable('client_ip_lists.json') as $i => $row) { + foreach ([$row['canonical'], $row['ip']] as $ip) { + $t->same($row['expected'], CanonicalIp::ipInList($ip, $row['entries']), sprintf('row %d ip=%s entries=%s', $i, $ip, json_encode($row['entries']))); + } +} + +function engineWith(mixed ...$args): GuardEngine +{ + return new GuardEngine(new SecurityConfig(...$args, enableRedis: false)); +} + +function status(GuardEngine $engine, SimpleGuardRequest $request): int +{ + return $engine->execute($request)?->statusCode() ?? 200; +} + +$t->section('a blacklist entry blocks an IPv4-mapped client'); +foreach (['127.0.0.1', '127.0.0.0/8', '::ffff:127.0.0.1'] as $entry) { + $engine = engineWith(blacklist: [$entry]); + foreach (['127.0.0.1', '::ffff:127.0.0.1'] as $peer) { + $t->same(403, status($engine, makeRequest($peer, null)), "blacklist {$entry} peer {$peer}"); + } + $t->same(200, status($engine, makeRequest('::ffff:203.0.113.9', null)), "blacklist {$entry} leaves another client alone"); +} + +$t->section('a whitelist admits an IPv4-mapped client'); +$engine = engineWith(whitelist: ['10.0.0.0/8']); +$t->same(200, status($engine, makeRequest('::ffff:10.1.1.1', null)), 'whitelisted mapped client passes'); +$t->same(403, status($engine, makeRequest('::ffff:203.0.113.9', null)), 'non-whitelisted mapped client denied'); + +$t->section('bans stored for the IPv4 form block the IPv4-mapped form'); +$engine = engineWith(enableIpBanning: true); +$engine->banManager()->ban('198.51.100.7', 300, 'test'); +$t->same(403, status($engine, makeRequest('::ffff:198.51.100.7', null)), 'mapped peer is banned'); +$t->same(403, status($engine, makeRequest('198.51.100.7', null)), 'plain peer is banned'); + +$t->section('emergency whitelist admits an IPv4-mapped client'); +$engine = engineWith(emergencyMode: true, emergencyWhitelist: ['198.51.100.9']); +$t->same(200, status($engine, makeRequest('::ffff:198.51.100.9', null)), 'whitelisted mapped client passes'); +$t->same(503, status($engine, makeRequest('::ffff:198.51.100.10', null)), 'other client denied'); + +$t->section('rate-limit keys: both spellings of one client share a counter'); +$engine = engineWith(rateLimit: 2, rateLimitWindow: 60); +$t->same(200, status($engine, makeRequest('::ffff:198.51.100.8', null)), 'hit 1'); +$t->same(200, status($engine, makeRequest('198.51.100.8', null)), 'hit 2'); +$t->same(429, status($engine, makeRequest('::ffff:198.51.100.8', null)), 'hit 3 is limited'); + +$t->section('a forwarded identity from a trusted proxy reaches every check'); +$engine = engineWith(trustedProxies: ['127.0.0.1'], blacklist: ['203.0.113.77']); +$request = makeRequest('127.0.0.1', '::ffff:203.0.113.77'); +$t->same(403, status($engine, $request), 'blacklisted forwarded client'); +$t->same('203.0.113.77', $request->state()->clientIp, 'state.client_ip is the forwarded client'); + +$t->section('a spoofed X-Forwarded-For from an untrusted peer reaches no check'); +$engine = engineWith(trustedProxies: ['127.0.0.1'], whitelist: ['10.9.9.9']); +$request = makeRequest('203.0.113.5', '10.9.9.9'); +$t->same(403, status($engine, $request), 'spoofed whitelist address is ignored'); +$t->same('203.0.113.5', $request->state()->clientIp, 'identity stays the peer'); + +$t->section('a no-peer request resolves through the unix trusted-proxy chain'); +try { + $engine = engineWith(trustedProxies: ['unix'], blacklist: ['203.0.113.31'], failSecure: false); + $request = makeRequest(null, '203.0.113.31'); + $t->same(403, status($engine, $request), 'blacklisted client behind a unix proxy'); + $t->same('203.0.113.31', $request->state()->clientIp, 'state.client_ip'); +} catch (\InvalidArgumentException $e) { + $t->ok(false, 'trusted_proxies accepts the unix token: ' . $e->getMessage()); +} + +$t->section('untrusted X-Forwarded-For is reported once as a spoof event with the canonical peer'); +$config = new SecurityConfig(enableRedis: false, trustedProxies: ['127.0.0.1'], agentEnableEvents: true); +$engine = new GuardEngine($config); +$engine->execute(makeRequest('::ffff:203.0.113.5', '1.2.3.4, evil')); +$spoof = array_values(array_filter( + $engine->eventBus()->drain(), + static fn (SecurityEvent $e): bool => $e->actionTaken === 'spoofing_detected' +)); +$t->same(1, count($spoof), 'one spoofing_detected event'); +$t->same('suspicious_request', $spoof[0]->eventType ?? null, 'event type'); +$t->same('203.0.113.5', $spoof[0]->ipAddress ?? null, 'event carries the canonical peer'); +$t->same('ip_extraction', $spoof[0]->handlerName ?? null, 'handler name'); +$t->same('Potential IP spoof attempt: X-Forwarded-For header 1.2.3.4,[REDACTED]', $spoof[0]->reason ?? null, 'redacted reason'); + +$t->section('spoof and preemption logging'); +ClientIpResolver::resetWarnings(); +$logger = new SimpleRequestLogger(); +$config = new SecurityConfig(enableRedis: false, trustedProxies: ['10.0.0.1']); +ClientIpResolver::extract(makeRequest('8.8.8.8', '1.2.3.4'), $config, $logger); +ClientIpResolver::extract(makeRequest('192.168.1.5', '1.2.3.4'), $config, $logger); +$levels = array_map(static fn (array $r): string => $r['level'], $logger->records()); +$t->same(['warning', 'debug'], $levels, 'a public peer is logged at warning, a private peer at debug'); +ClientIpResolver::resetWarnings(); +$logger = new SimpleRequestLogger(); +$open = new SecurityConfig(enableRedis: false); +ClientIpResolver::extract(makeRequest('8.8.8.8', '1.2.3.4'), $open, $logger); +$t->same(0, count($logger->records()), 'a chain without the peer inside it is not a preemption'); +ClientIpResolver::extract(makeRequest('8.8.8.8', '1.2.3.4, 8.8.8.8'), $open, $logger); +ClientIpResolver::extract(makeRequest('8.8.8.8', '8.8.8.8'), $open, $logger); +$t->same(1, count($logger->records()), 'the preemption warning is logged once'); + +$t->section('chain warnings are logged once'); +foreach ([ + ['chain too short', '9.9.9.9', ['127.0.0.1'], 2, 'fewer than the configured'], + ['selected entry is a trusted proxy', '1.2.3.4, 10.0.0.2', ['127.0.0.1', '10.0.0.2'], 1, 'itself listed in trusted_proxies'], + ['depth over-counts hops', '9.9.9.9, 1.2.3.4', ['127.0.0.1'], 2, 'over-counts'], +] as [$name, $xff, $proxies, $depth, $text]) { + ClientIpResolver::resetWarnings(); + $logger = new SimpleRequestLogger(); + $config = new SecurityConfig(enableRedis: false, trustedProxies: $proxies, trustedProxyDepth: $depth); + ClientIpResolver::extract(makeRequest('127.0.0.1', $xff), $config, $logger); + ClientIpResolver::extract(makeRequest('127.0.0.1', $xff), $config, $logger); + $hits = array_filter($logger->records(), static fn (array $r): bool => str_contains($r['message'], $text)); + $t->same(1, count($hits), "{$name} is logged once"); +} + +$t->section('address and network parsing follow ipaddress'); +$t->same(null, CanonicalIp::parseNetwork('10.0.0.1'), 'a network needs a prefix'); +foreach (['10.0.0.0/', '10.0.0.0/x', 'fe80::1%eth0/64', 'junk/8', '10.0.0.0/33', '::/129'] as $bad) { + $t->same(null, CanonicalIp::parseNetwork($bad), "{$bad} is not a network"); +} +foreach (['fe80::1%', 'fe80::1%a%b', 'fe80::1%a/b', '1.2.3.4%eth0', '[::1]', 'junk'] as $bad) { + $t->same(null, CanonicalIp::referenceText($bad), "{$bad} is not an address"); +} +foreach (['fe80::1%eth0/64', 'junk/8'] as $bad) { + try { + CanonicalIp::canonicalNetwork($bad); + $t->ok(false, "{$bad} must be rejected"); + } catch (\InvalidArgumentException) { + $t->ok(true, "{$bad} is rejected"); + } +} +$t->same('::ffff:10.0.0.0/104', CanonicalIp::canonicalNetwork('::ffff:10.0.0.1/104'), 'a mapped network keeps its family'); + +exit($t->done('test_client_ip')); diff --git a/bin/test_state.php b/bin/test_state.php index 699e46d..b9e1710 100644 --- a/bin/test_state.php +++ b/bin/test_state.php @@ -109,7 +109,7 @@ public function sendUnbanEvent(string $ip): void $t->same('2001:db8::1', CanonicalIp::canonicalize('[2001:0DB8:0000:0000:0000:0000:0000:0001]'), 'bracketed full IPv6 compressed + lowercased'); $t->same('192.168.1.1', CanonicalIp::canonicalize('::ffff:192.168.1.1'), 'IPv4-mapped collapsed'); $t->same('10.0.0.1', CanonicalIp::canonicalize('[::ffff:10.0.0.1]'), 'bracketed IPv4-mapped collapsed'); -$t->same('::ffff:102:304%eth0', CanonicalIp::canonicalize('::ffff:1.2.3.4%eth0'), 'scoped IPv4-mapped stays IPv6-rendered with scope'); +$t->same('::ffff:1.2.3.4%eth0', CanonicalIp::canonicalize('::ffff:1.2.3.4%eth0'), 'scoped IPv4-mapped keeps its scope and the reference str() dotted form'); $t->same('fe80::1%eth0', CanonicalIp::canonicalize('fe80::1%eth0'), 'scope id preserved'); $t->same('::', CanonicalIp::canonicalize('0:0:0:0:0:0:0:0'), 'all-zero compressed'); $t->same('2001:0:0:1::1', CanonicalIp::canonicalize('2001:0:0:1:0:0:0:1'), 'longest zero run wins, leftmost on tie'); diff --git a/src/Config/SecurityConfig.php b/src/Config/SecurityConfig.php index e719fcc..c90a6df 100644 --- a/src/Config/SecurityConfig.php +++ b/src/Config/SecurityConfig.php @@ -492,7 +492,7 @@ public function __construct( $this->passiveMode = $passiveMode ?? false; $this->failSecure = $failSecure ?? true; $this->routeResolutionStrict = $routeResolutionStrict ?? false; - $this->trustedProxies = $this->validateIpCidrList($trustedProxies ?? [], 'trusted_proxies'); + $this->trustedProxies = $this->validateIpCidrList($trustedProxies ?? [], 'trusted_proxies', trustedProxies: true); $this->trustedProxyDepth = $trustedProxyDepth ?? 1; if ($this->trustedProxyDepth < 1) { throw new \InvalidArgumentException('trusted_proxy_depth must be >= 1'); @@ -986,13 +986,20 @@ private static function fieldNameToArg(string $field): string } /** @param list $entries @return list */ - private function validateIpCidrList(array $entries, string $field): array + private function validateIpCidrList(array $entries, string $field, bool $trustedProxies = false): array { $out = []; foreach ($entries as $entry) { if (!is_string($entry) || $entry === '') { throw new \InvalidArgumentException("{$field}: entries must be non-empty IP or CIDR strings"); } + // The reference unix-socket token (_validate_trusted_proxies_value + // allow_unix): it marks deployments with no peer address and + // never matches a real IP. + if ($trustedProxies && $entry === 'unix') { + $out[] = 'unix'; + continue; + } if (str_contains($entry, '/')) { try { $out[] = CanonicalIp::canonicalNetwork($entry); @@ -1000,7 +1007,9 @@ private function validateIpCidrList(array $entries, string $field): array throw new \InvalidArgumentException("{$field}: invalid IP/CIDR entry '{$entry}': {$e->getMessage()}"); } } else { - $parsed = CanonicalIp::parse($entry); + // trusted_proxies keeps the reference str(ip_address()) spelling: + // the connecting peer is compared to it as raw text. + $parsed = $trustedProxies ? CanonicalIp::strOf($entry) : CanonicalIp::referenceText($entry); if ($parsed === null) { throw new \InvalidArgumentException("{$field}: invalid IP/CIDR entry '{$entry}'"); } diff --git a/src/Events/EventBus.php b/src/Events/EventBus.php index 375cda0..6f85dd3 100644 --- a/src/Events/EventBus.php +++ b/src/Events/EventBus.php @@ -140,6 +140,39 @@ public function sendHandlerEvent( )); } + /** + * The reference send_agent_event (_utils/agent_events.py): a + * handler-named envelope that also carries the request surface + * (endpoint, method, user agent). Like the reference it consults + * neither the agent_enable_events gate nor the mute filter. + * + * @param array $metadata + */ + public function sendRequestHandlerEvent( + string $eventType, + string $handlerName, + string $ipAddress, + object $request, + string $actionTaken, + string $reason, + array $metadata = [] + ): void { + $event = $this->buildEvent($eventType, $request, $ipAddress, null, $actionTaken, $reason, $metadata); + $this->dispatch(new SecurityEvent( + timestamp: $event->timestamp, + eventType: $event->eventType, + ipAddress: $event->ipAddress, + country: null, + userAgent: $event->userAgent, + actionTaken: $event->actionTaken, + reason: $event->reason, + endpoint: $event->endpoint, + method: $event->method, + handlerName: $handlerName, + metadata: $metadata + )); + } + /** * HTTPS violation dispatch: a route-level require_https emits * decorator_violation (decorator_type authentication, violation_type diff --git a/src/Ip/CanonicalIp.php b/src/Ip/CanonicalIp.php index a550538..8549541 100644 --- a/src/Ip/CanonicalIp.php +++ b/src/Ip/CanonicalIp.php @@ -15,23 +15,121 @@ public static function stripBrackets(string $value): string return $value; } + /** + * The reference _canonicalize_ip: brackets stripped, an IPv4-mapped + * IPv6 address without a zone becomes IPv4, a zoned address keeps its + * zone, anything unparseable is returned unchanged. + */ public static function canonicalize(string $value): string { - $stripped = self::stripBrackets($value); - $scopePos = strpos($stripped, '%'); - if ($scopePos !== false) { - $addrPart = substr($stripped, 0, $scopePos); - $scope = substr($stripped, $scopePos + 1); - $text = self::canonicalText($addrPart, allowV4MappedCollapse: false); - if ($text === null) { - return $value; - } + return self::referenceText(self::stripBrackets($value)) ?? $value; + } + + /** + * The reference _canonical_ip_text over ip_address(): null when the + * value is not an address (Python ipaddress acceptance: an IPv6 zone id + * is accepted, brackets, ports and an IPv4 zone are not). + */ + public static function referenceText(string $value): ?string + { + $raw = self::parseRaw($value); + if ($raw === null) { + return null; + } + if (strlen($raw['bytes']) === 16 && $raw['scope'] === null && self::isV4Mapped($raw['bytes'])) { + return inet_ntop(substr($raw['bytes'], 12, 4)); + } + + return self::renderRaw($raw); + } + + /** The reference str(ip_address(value)) (no IPv4-mapped collapse): null when invalid. */ + public static function strOf(string $value): ?string + { + $raw = self::parseRaw($value); + + return $raw === null ? null : self::renderRaw($raw); + } + + /** + * Packed address bytes of ip_address(value), zone dropped, no + * IPv4-mapped collapse (an IPv6 address stays a 16-byte value). + */ + public static function packedRaw(string $value): ?string + { + $raw = self::parseRaw($value); + + return $raw === null ? null : $raw['bytes']; + } + + /** + * ip_network(value, strict=False): the packed network address and the + * prefix length, null when invalid (the reference ValueError). + * + * @return array{0: string, 1: int}|null + */ + public static function parseNetwork(string $value): ?array + { + $slash = strrpos($value, '/'); + if ($slash === false) { + return null; + } + $prefix = substr($value, $slash + 1); + $addr = substr($value, 0, $slash); + if ($prefix === '' || !ctype_digit($prefix) || str_contains($addr, '%')) { + return null; + } + $bytes = self::packedRaw($addr); + if ($bytes === null || (int) $prefix > strlen($bytes) * 8) { + return null; + } + + return [$bytes, (int) $prefix]; + } + + /** Whether packed $ip lies in the packed network (the address families must match). */ + public static function bytesInNetwork(string $ip, string $network, int $prefixLen): bool + { + if (strlen($ip) !== strlen($network)) { + return false; + } + $bytes = intdiv($prefixLen, 8); + if (substr($ip, 0, $bytes) !== substr($network, 0, $bytes)) { + return false; + } + $rem = $prefixLen % 8; + if ($rem === 0) { + return true; + } + $mask = (0xff << (8 - $rem)) & 0xff; + + return (ord($ip[$bytes]) & $mask) === (ord($network[$bytes]) & $mask); + } - return $text . '%' . $scope; + /** + * The reference _ip_in_list (_utils/access_control.py): a bare entry + * matches when its canonical form equals the canonical client address, + * a CIDR entry by network membership (a zone id never defeats it). A + * malformed CIDR entry matches nothing. + * + * @param list $entries + */ + public static function ipInList(string $ip, array $entries): bool + { + $canonical = self::canonicalize($ip); + $packed = self::packedRaw($canonical); + foreach ($entries as $entry) { + if (str_contains($entry, '/')) { + $network = self::parseNetwork($entry); + if ($network !== null && $packed !== null && self::bytesInNetwork($packed, $network[0], $network[1])) { + return true; + } + } elseif ($canonical === self::canonicalize($entry)) { + return true; + } } - $text = self::canonicalText($stripped); - return $text ?? $value; + return false; } public static function parse(string $value): ?string @@ -47,8 +145,8 @@ public static function canonicalNetwork(string $cidr): string } $addrPart = substr($cidr, 0, $slash); $prefixLen = filter_var(substr($cidr, $slash + 1), FILTER_VALIDATE_INT); - $addr = self::canonicalText(self::stripBrackets($addrPart)); - if ($addr === null || $prefixLen === false) { + $addr = self::strOf(self::stripBrackets($addrPart)); + if ($addr === null || str_contains($addr, '%') || $prefixLen === false) { throw new \InvalidArgumentException("Invalid CIDR network '{$cidr}'"); } $bytes = @inet_pton($addr); @@ -61,7 +159,7 @@ public static function canonicalNetwork(string $cidr): string } $masked = self::clearHostBits($bytes, $prefixLen); - return self::bytesToText($masked, isV4: strlen($bytes) === 4) . '/' . $prefixLen; + return self::renderRaw(['bytes' => $masked, 'scope' => null]) . '/' . $prefixLen; } public static function networkContains(string $network, string $ip): bool @@ -95,6 +193,48 @@ public static function isLoopback(string $ip): bool && str_starts_with(inet_ntop(substr($bytes, 12, 4)), '127.'); } + /** @return array{bytes: string, scope: ?string}|null */ + private static function parseRaw(string $value): ?array + { + if (filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { + $bytes = @inet_pton($value); + + return $bytes === false ? null : ['bytes' => $bytes, 'scope' => null]; + } + $addr = $value; + $scope = null; + $pct = strpos($value, '%'); + if ($pct !== false) { + $addr = substr($value, 0, $pct); + $scope = substr($value, $pct + 1); + if ($scope === '' || str_contains($scope, '%') || str_contains($scope, '/')) { + return null; + } + } + if (filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) { + return null; + } + $bytes = @inet_pton($addr); + if ($bytes === false || strlen($bytes) !== 16) { + return null; + } + + return ['bytes' => $bytes, 'scope' => $scope]; + } + + /** @param array{bytes: string, scope: ?string} $raw */ + private static function renderRaw(array $raw): string + { + if (strlen($raw['bytes']) === 4) { + return inet_ntop($raw['bytes']); + } + $text = self::isV4Mapped($raw['bytes']) + ? '::ffff:' . inet_ntop(substr($raw['bytes'], 12, 4)) + : self::bytesToText($raw['bytes']); + + return $raw['scope'] === null ? $text : $text . '%' . $raw['scope']; + } + private static function canonicalText(string $value, bool $allowV4MappedCollapse = true): ?string { if (filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { @@ -111,7 +251,7 @@ private static function canonicalText(string $value, bool $allowV4MappedCollapse return inet_ntop(substr($bytes, 12, 4)); } - return self::bytesToText($bytes, isV4: false); + return self::bytesToText($bytes); } private static function isV4Mapped(string $bytes16): bool @@ -141,11 +281,8 @@ private static function clearHostBits(string $bytes, int $prefixLen): string return $out; } - private static function bytesToText(string $bytes, bool $isV4): string + private static function bytesToText(string $bytes): string { - if ($isV4) { - return inet_ntop($bytes); - } $groups = array_values(unpack('n8', $bytes)); $hex = array_map(dechex(...), $groups); $bestStart = -1; diff --git a/src/Pipeline/BlockEvents.php b/src/Pipeline/BlockEvents.php index 879473e..1c49949 100644 --- a/src/Pipeline/BlockEvents.php +++ b/src/Pipeline/BlockEvents.php @@ -4,6 +4,8 @@ namespace RenzoFranceschini\GuardCore\Pipeline; +use RenzoFranceschini\GuardCore\Ip\CanonicalIp; + final class BlockEvents { public const ON_BLOCK_EXCLUDED_CHECK_NAMES = ['custom_request', 'custom_validators', 'https_enforcement']; @@ -21,7 +23,8 @@ public static function buildPayload( bool $passiveMode, ?int $statusCode ): array { - $clientIp = $request->state()->clientIp ?? $request->clientHost(); + $host = $request->clientHost(); + $clientIp = $request->state()->clientIp ?? ($host !== null && $host !== '' ? CanonicalIp::canonicalize($host) : $host); return [ 'check_name' => $checkName, diff --git a/src/Pipeline/CheckFactory.php b/src/Pipeline/CheckFactory.php index c0d3a80..08c4a9c 100644 --- a/src/Pipeline/CheckFactory.php +++ b/src/Pipeline/CheckFactory.php @@ -114,7 +114,7 @@ public function buildChecks(SecurityConfig $config, ?array $routeConfigs = null) private function buildOne(string $name, SecurityConfig $config): ?SecurityCheck { return match ($name) { - 'route_config' => new RouteConfigCheck($config, $this->responseFactory, $this->routeResolver), + 'route_config' => new RouteConfigCheck($config, $this->responseFactory, $this->routeResolver, $this->eventBus), 'emergency_mode' => new EmergencyModeCheck($config, $this->responseFactory), 'https_enforcement' => new HttpsEnforcementCheck($config, $this->responseFactory), 'request_logging' => new RequestLoggingCheck($config, $this->responseFactory), diff --git a/src/Pipeline/Checks/EmergencyModeCheck.php b/src/Pipeline/Checks/EmergencyModeCheck.php index e05887a..4d58938 100644 --- a/src/Pipeline/Checks/EmergencyModeCheck.php +++ b/src/Pipeline/Checks/EmergencyModeCheck.php @@ -39,19 +39,10 @@ public function check(GuardRequest $request): ?GuardResponse $clientIp = $request->state()->clientIp; if ($clientIp === null || $clientIp === '') { - $clientIp = ClientIpResolver::extract($request, $this->config); + $clientIp = ClientIpResolver::extract($request, $this->config, $this->logger); } - $parsed = CanonicalIp::parse($clientIp); - $isWhitelisted = false; - if ($parsed !== null) { - foreach ($this->config->emergencyWhitelist as $entry) { - if (self::matches($entry, $clientIp, $parsed)) { - $isWhitelisted = true; - break; - } - } - } + $isWhitelisted = CanonicalIp::ipInList($clientIp, $this->config->emergencyWhitelist); if (!$isWhitelisted) { $reason = "[EMERGENCY MODE] Access denied for IP {$clientIp}"; @@ -66,13 +57,4 @@ public function check(GuardRequest $request): ?GuardResponse return null; } - - private static function matches(string $entry, string $ip, string $parsed): bool - { - if (str_contains($entry, '/')) { - return CanonicalIp::networkContains($entry, $parsed); - } - - return $ip === $entry; - } } diff --git a/src/Pipeline/Checks/IpSecurityCheck.php b/src/Pipeline/Checks/IpSecurityCheck.php index 940f918..988e78b 100644 --- a/src/Pipeline/Checks/IpSecurityCheck.php +++ b/src/Pipeline/Checks/IpSecurityCheck.php @@ -97,7 +97,7 @@ public function check(GuardRequest $request): ?GuardResponse $whitelistActive = $whitelist !== null && $whitelist !== []; foreach ($this->config->blacklist as $entry) { - if (self::matches($entry, $clientIp)) { + if (CanonicalIp::ipInList($clientIp, [$entry])) { return $this->deny($request, $clientIp, self::GENERIC_LIST_BLOCK_REASON); } } @@ -105,7 +105,7 @@ public function check(GuardRequest $request): ?GuardResponse if ($whitelistActive) { $allowed = false; foreach ($whitelist as $entry) { - if (self::matches($entry, $clientIp)) { + if (CanonicalIp::ipInList($clientIp, [$entry])) { $allowed = true; break; } @@ -200,7 +200,7 @@ public function check(GuardRequest $request): ?GuardResponse // the flag entirely. if (!$routeOverridesIpLists && $this->config->exemptIps !== []) { foreach ($this->config->exemptIps as $entry) { - if (self::matches($entry, $clientIp)) { + if (CanonicalIp::ipInList($clientIp, [$entry])) { $request->state()->isExempt = true; break; } @@ -275,13 +275,7 @@ private function routeCountryVerdict(string $clientIp, RouteConfig $routeConfig) /** @param list $entries */ private function ipInList(string $ip, array $entries): bool { - foreach ($entries as $entry) { - if (self::matches($entry, $ip)) { - return true; - } - } - - return false; + return CanonicalIp::ipInList($ip, $entries); } private function hasCountryRules(): bool @@ -368,15 +362,4 @@ private function deny(GuardRequest $request, string $clientIp, string $accessRea return null; } - - private static function matches(string $entry, string $ip): bool - { - if (str_contains($entry, '/')) { - $addr = CanonicalIp::parse($ip); - - return $addr !== null && CanonicalIp::networkContains($entry, $addr); - } - - return CanonicalIp::parse($ip) === $entry; - } } diff --git a/src/Pipeline/Checks/RouteConfigCheck.php b/src/Pipeline/Checks/RouteConfigCheck.php index 7a71a15..350f623 100644 --- a/src/Pipeline/Checks/RouteConfigCheck.php +++ b/src/Pipeline/Checks/RouteConfigCheck.php @@ -5,6 +5,7 @@ namespace RenzoFranceschini\GuardCore\Pipeline\Checks; use RenzoFranceschini\GuardCore\Config\SecurityConfig; +use RenzoFranceschini\GuardCore\Events\EventBus; use RenzoFranceschini\GuardCore\Pipeline\SecurityCheck; use RenzoFranceschini\GuardCore\Request\ClientIpResolver; use RenzoFranceschini\GuardCore\Request\GuardRequest; @@ -17,7 +18,8 @@ final class RouteConfigCheck extends SecurityCheck public function __construct( SecurityConfig $config, GuardResponseFactory $responseFactory, - private readonly RouteResolver $routeResolver + private readonly RouteResolver $routeResolver, + private readonly ?EventBus $eventBus = null ) { parent::__construct($config, $responseFactory); } @@ -39,7 +41,7 @@ public function check(GuardRequest $request): ?GuardResponse if ($resolved !== null || $state->routeConfig === null) { $state->routeConfig = $resolved; } - $state->clientIp = ClientIpResolver::extract($request, $this->config); + $state->clientIp = ClientIpResolver::extract($request, $this->config, $this->logger, $this->eventBus); if ($this->config->routeResolutionStrict && $state->guardRouteUnresolved === true) { $reason = 'Route resolution failed; per-route decorator config could not be applied'; diff --git a/src/Request/ClientIpResolver.php b/src/Request/ClientIpResolver.php index f778b52..8358881 100644 --- a/src/Request/ClientIpResolver.php +++ b/src/Request/ClientIpResolver.php @@ -5,54 +5,98 @@ namespace RenzoFranceschini\GuardCore\Request; use RenzoFranceschini\GuardCore\Config\SecurityConfig; +use RenzoFranceschini\GuardCore\Detection\HeaderExclusions; +use RenzoFranceschini\GuardCore\Events\EventBus; +use RenzoFranceschini\GuardCore\Events\EventTypes; use RenzoFranceschini\GuardCore\Ip\CanonicalIp; +use RenzoFranceschini\GuardCore\Logging\LogSanitizer; +use RenzoFranceschini\GuardCore\Logging\RequestLogger; +use RenzoFranceschini\GuardCore\Logging\SimpleRequestLogger; +/** + * Client identity resolution, the port of the reference + * guard_core/_utils/ip_extraction.py (extract_client_ip): the connecting + * address is canonicalized (an IPv4-mapped IPv6 address becomes IPv4), the + * X-Forwarded-For chain is honored only from a trusted connecting peer + * (compared as raw text or by CIDR membership), entries are counted from + * the right at trusted_proxy_depth, and a chain from an untrusted peer is + * reported as a spoof attempt and ignored. + */ final class ClientIpResolver { public const UNKNOWN_CLIENT_IDENTITY = 'unknown'; - public static function extract(GuardRequest $request, SecurityConfig $config): string + /** @var array the once-per-process warning gates */ + private static array $warned = []; + + /** Clears the once-per-process warning gates (the reference module flags). */ + public static function resetWarnings(): void { + self::$warned = []; + } + + public static function extract( + GuardRequest $request, + SecurityConfig $config, + ?RequestLogger $logger = null, + ?EventBus $eventBus = null + ): string { $cached = $request->state()->clientIp; if ($cached !== null && $cached !== '') { return $cached; } + $logger ??= new SimpleRequestLogger(); + $forwardedFor = $request->headers()->get('x-forwarded-for'); $connectingIp = $request->clientHost(); - if ($connectingIp === null) { + if ($connectingIp === null || $connectingIp === '') { return in_array('unix', $config->trustedProxies, true) ? self::resolveForwardedChain( self::UNKNOWN_CLIENT_IDENTITY, - $request->headers()->get('x-forwarded-for'), + $forwardedFor, $config->trustedProxyDepth, - $config->trustedProxies + $config->trustedProxies, + $logger ) : self::UNKNOWN_CLIENT_IDENTITY; } - $canonical = CanonicalIp::parse($connectingIp) ?? $connectingIp; - $forwardedFor = $request->headers()->get('x-forwarded-for'); + $canonical = CanonicalIp::canonicalize($connectingIp); if ($config->trustedProxies === []) { + self::warnPreempted($connectingIp, $forwardedFor, $logger); + return $canonical; } if (!self::isTrustedProxy($connectingIp, $config->trustedProxies)) { - return $canonical; + return self::handleUntrustedProxy($request, $connectingIp, $canonical, $forwardedFor, $logger, $eventBus); } return self::resolveForwardedChain( $canonical, $forwardedFor, $config->trustedProxyDepth, - $config->trustedProxies + $config->trustedProxies, + $logger ); } - /** @param list $trustedProxies */ + /** + * The reference _is_trusted_proxy: the raw connecting address against + * each entry, bare entries by text equality and CIDR entries by + * membership (an IPv4-mapped IPv6 peer is not a member of an IPv4 + * network). An unparseable peer, or a malformed CIDR entry reached + * before a match, is never trusted (the reference ValueError arm). + * + * @param list $trustedProxies + */ public static function isTrustedProxy(string $connectingIp, array $trustedProxies): bool { - $parsed = CanonicalIp::parse($connectingIp); + $packed = CanonicalIp::packedRaw($connectingIp); + if ($packed === null) { + return false; + } foreach ($trustedProxies as $proxy) { if (!str_contains($proxy, '/')) { if ($connectingIp === $proxy) { @@ -60,7 +104,11 @@ public static function isTrustedProxy(string $connectingIp, array $trustedProxie } continue; } - if ($parsed !== null && CanonicalIp::networkContains($proxy, $parsed)) { + $network = CanonicalIp::parseNetwork($proxy); + if ($network === null) { + return false; + } + if (CanonicalIp::bytesInNetwork($packed, $network[0], $network[1])) { return true; } } @@ -75,25 +123,229 @@ public static function resolveForwardedChain( string $connectingIp, ?string $forwardedFor, int $proxyDepth, - array $trustedProxies + array $trustedProxies, + ?RequestLogger $logger = null ): string { if ($forwardedFor === null || $forwardedFor === '') { return $connectingIp; } + $logger ??= new SimpleRequestLogger(); + + $ips = self::forwardedIps($forwardedFor); + $chainLength = count($ips); + if ($chainLength < $proxyDepth) { + self::warnChainTooShort($forwardedFor, $chainLength, $logger); - $ips = array_map('trim', explode(',', $forwardedFor)); - $ips = array_values(array_filter($ips, static fn (string $ip): bool => $ip !== '')); - if ($ips === []) { return $connectingIp; } - if (count($ips) < $proxyDepth) { - return $connectingIp; + if ($trustedProxies !== []) { + $unlisted = 0; + foreach (array_slice($ips, max(0, $chainLength - $proxyDepth + 1)) as $entry) { + if (!self::isTrustedProxy(CanonicalIp::canonicalize($entry), $trustedProxies)) { + $unlisted++; + } + } + if ($unlisted > 0) { + self::warnDepthOvercounts($unlisted, $forwardedFor, $logger); + + return self::resolveRightToLeft($ips, $trustedProxies) ?? $connectingIp; + } + } + + $client = self::extractFromForwardedHeader($ips, $proxyDepth); + if ($client !== null && $client !== '') { + if (self::isTrustedProxy($client, $trustedProxies)) { + self::warnSelectedTrusted($client, $logger); + } + + return $client; } + return $connectingIp; + } + + /** @return list */ + private static function forwardedIps(string $forwardedFor): array + { + return array_map( + static fn (string $entry): string => HeaderExclusions::stripForwardedEntryPort(trim($entry)), + explode(',', $forwardedFor) + ); + } + + /** @param list $ips */ + private static function extractFromForwardedHeader(array $ips, int $proxyDepth): ?string + { + // The caller already proved count($ips) >= $proxyDepth and the config + // guarantees trusted_proxy_depth >= 1. $candidate = $ips[count($ips) - $proxyDepth]; - $parsed = CanonicalIp::parse($candidate); + $text = CanonicalIp::referenceText($candidate); + if ($text === null || self::hasMetachar($candidate)) { + return null; + } + + return $text; + } + + /** + * The rightmost entry that is not a trusted proxy is the client; null + * when every entry is trusted or the first untrusted one is malformed. + * + * @param list $ips + * @param list $trustedProxies + */ + private static function resolveRightToLeft(array $ips, array $trustedProxies): ?string + { + foreach (array_reverse($ips) as $entry) { + if (self::isTrustedProxy(CanonicalIp::canonicalize($entry), $trustedProxies)) { + continue; + } + $text = CanonicalIp::referenceText($entry); + if ($text === null || self::hasMetachar($entry)) { + return null; + } + + return $text; + } + + return null; + } - return $parsed ?? $connectingIp; + private static function hasMetachar(string $candidate): bool + { + return strpbrk($candidate, '*?[]\\') !== false; + } + + private static function isPrivateOrLoopback(string $ip): bool + { + $packed = CanonicalIp::packedRaw($ip); + if ($packed === null) { + return false; + } + $privateRanges = strlen($packed) === 4 + ? ['0.0.0.0/8', '10.0.0.0/8', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12', '192.0.0.0/29', '192.0.0.170/31', '192.0.2.0/24', '192.168.0.0/16', '198.18.0.0/15', '198.51.100.0/24', '203.0.113.0/24', '240.0.0.0/4', '255.255.255.255/32'] + : ['::1/128', '::/128', '::ffff:0:0/96', '64:ff9b:1::/48', '100::/64', '2001::/23', '2001:db8::/32', '2002::/16', 'fc00::/7', 'fe80::/10']; + foreach ($privateRanges as $range) { + $network = CanonicalIp::parseNetwork($range); + if ($network !== null && CanonicalIp::bytesInNetwork($packed, $network[0], $network[1])) { + return true; + } + } + + return false; + } + + private static function displayChain(string $value): string + { + $displayed = []; + foreach (explode(',', $value) as $part) { + $candidate = HeaderExclusions::stripForwardedEntryPort(trim($part)); + $displayed[] = CanonicalIp::referenceText($candidate) !== null ? $part : '[REDACTED]'; + } + + return implode(',', $displayed); + } + + private static function handleUntrustedProxy( + GuardRequest $request, + string $connectingIp, + string $canonicalConnectingIp, + ?string $forwardedFor, + RequestLogger $logger, + ?EventBus $eventBus + ): string { + if ($forwardedFor !== null && $forwardedFor !== '') { + self::warnPreempted($connectingIp, $forwardedFor, $logger); + $display = self::displayChain($forwardedFor); + $logger->log( + self::isPrivateOrLoopback($connectingIp) ? 'debug' : 'warning', + 'Potential IP spoof attempt: X-Forwarded-For header (' . LogSanitizer::sanitize($display) + . ') received from untrusted IP ' . LogSanitizer::sanitize($connectingIp) + ); + $eventBus?->sendRequestHandlerEvent( + EventTypes::EVENT_SUSPICIOUS_REQUEST, + 'ip_extraction', + $canonicalConnectingIp, + $request, + 'spoofing_detected', + 'Potential IP spoof attempt: X-Forwarded-For header ' . $display + ); + } + + return $canonicalConnectingIp; + } + + private static function once(string $flag): bool + { + if (isset(self::$warned[$flag])) { + return false; + } + self::$warned[$flag] = true; + + return true; + } + + private static function warnPreempted(string $connectingIp, ?string $forwardedFor, RequestLogger $logger): void + { + if (isset(self::$warned['preempted']) || $forwardedFor === null || $forwardedFor === '') { + return; + } + $entries = array_map('trim', explode(',', $forwardedFor)); + if (!in_array($connectingIp, $entries, true)) { + return; + } + self::$warned['preempted'] = true; + $logger->log('warning', sprintf( + 'The connecting IP (%s) already appears inside its own X-Forwarded-For chain: the ' + . 'application server resolved the client from that header before guard-core ran. ' + . 'While it is, the address guard-core sees is whatever the client claimed, so a ' + . 'rotating X-Forwarded-For defeats rate limiting and IP banning. Declare the proxy ' + . 'via trusted_proxies / trusted_proxy_depth so guard-core resolves the real client ' + . 'itself. This warning is logged once.', + LogSanitizer::sanitize($connectingIp) + )); + } + + private static function warnChainTooShort(string $forwardedFor, int $chainLength, RequestLogger $logger): void + { + if (!self::once('too_short')) { + return; + } + $logger->log('warning', sprintf( + 'The X-Forwarded-For chain has only %d entries, fewer than the configured ' + . 'trusted_proxy_depth; chain was %s; falling back to the connecting peer as the ' + . 'client. This warning is logged once.', + $chainLength, + LogSanitizer::sanitize(self::displayChain($forwardedFor)) + )); + } + + private static function warnSelectedTrusted(string $entry, RequestLogger $logger): void + { + if (!self::once('selected_trusted')) { + return; + } + $logger->log('warning', sprintf( + 'trusted_proxy_depth selected %s from the X-Forwarded-For chain, but that address is ' + . 'itself listed in trusted_proxies; the chain likely has more proxy hops than ' + . 'trusted_proxy_depth accounts for. This warning is logged once.', + LogSanitizer::sanitize($entry) + )); + } + + private static function warnDepthOvercounts(int $unlisted, string $forwardedFor, RequestLogger $logger): void + { + if (!self::once('overcounts')) { + return; + } + $logger->log('warning', sprintf( + 'trusted_proxy_depth selected an entry from the X-Forwarded-For chain with %d ' + . 'entry/entries to its right that are not listed in trusted_proxies; chain was %s; ' + . 'the declared depth over-counts the real proxy hops. Set trusted_proxy_depth to the ' + . 'number of proxies that append to X-Forwarded-For. This warning is logged once.', + $unlisted, + LogSanitizer::sanitize(self::displayChain($forwardedFor)) + )); } } diff --git a/tests/data/client_ip_cases.json b/tests/data/client_ip_cases.json new file mode 100644 index 0000000..ae6a285 --- /dev/null +++ b/tests/data/client_ip_cases.json @@ -0,0 +1,898 @@ +[ +{ +"host": "2001:db8::1", +"xff": null, +"tp": [], +"depth": 1, +"expected": "2001:db8::1" +}, +{ +"host": "127.0.0.1", +"xff": "2001:db8::1", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "2001:db8::1" +}, +{ +"host": "2001:0db8::1", +"xff": null, +"tp": [], +"depth": 1, +"expected": "2001:db8::1" +}, +{ +"host": "127.0.0.1", +"xff": "2001:0db8::1", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "2001:db8::1" +}, +{ +"host": "2001:DB8::1", +"xff": null, +"tp": [], +"depth": 1, +"expected": "2001:db8::1" +}, +{ +"host": "127.0.0.1", +"xff": "2001:DB8::1", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "2001:db8::1" +}, +{ +"host": "2001:db8:0:0:0:0:0:1", +"xff": null, +"tp": [], +"depth": 1, +"expected": "2001:db8::1" +}, +{ +"host": "127.0.0.1", +"xff": "2001:db8:0:0:0:0:0:1", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "2001:db8::1" +}, +{ +"host": "::ffff:1.2.3.4", +"xff": null, +"tp": [], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "127.0.0.1", +"xff": "::ffff:1.2.3.4", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "1.2.3.4", +"xff": null, +"tp": [], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "127.0.0.1", +"xff": "1.2.3.4", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "::ffff:0102:0304", +"xff": null, +"tp": [], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "127.0.0.1", +"xff": "::ffff:0102:0304", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "::1", +"xff": null, +"tp": [], +"depth": 1, +"expected": "::1" +}, +{ +"host": "127.0.0.1", +"xff": "::1", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "::1" +}, +{ +"host": "::", +"xff": null, +"tp": [], +"depth": 1, +"expected": "::" +}, +{ +"host": "127.0.0.1", +"xff": "::", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "::" +}, +{ +"host": "::1.2.3.4", +"xff": null, +"tp": [], +"depth": 1, +"expected": "::102:304" +}, +{ +"host": "127.0.0.1", +"xff": "::1.2.3.4", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "::102:304" +}, +{ +"host": "64:ff9b::1.2.3.4", +"xff": null, +"tp": [], +"depth": 1, +"expected": "64:ff9b::102:304" +}, +{ +"host": "127.0.0.1", +"xff": "64:ff9b::1.2.3.4", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "64:ff9b::102:304" +}, +{ +"host": "1:0:2:3:4:5:6:7", +"xff": null, +"tp": [], +"depth": 1, +"expected": "1:0:2:3:4:5:6:7" +}, +{ +"host": "127.0.0.1", +"xff": "1:0:2:3:4:5:6:7", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "1:0:2:3:4:5:6:7" +}, +{ +"host": "2001:db8:0:0:1:0:0:1", +"xff": null, +"tp": [], +"depth": 1, +"expected": "2001:db8::1:0:0:1" +}, +{ +"host": "127.0.0.1", +"xff": "2001:db8:0:0:1:0:0:1", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "2001:db8::1:0:0:1" +}, +{ +"host": "[2001:db8::1]", +"xff": null, +"tp": [], +"depth": 1, +"expected": "2001:db8::1" +}, +{ +"host": "127.0.0.1", +"xff": "[2001:db8::1]", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "2001:db8::1" +}, +{ +"host": "[::ffff:1.2.3.4]", +"xff": null, +"tp": [], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "127.0.0.1", +"xff": "[::ffff:1.2.3.4]", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "fe80::1%eth0", +"xff": null, +"tp": [], +"depth": 1, +"expected": "fe80::1%eth0" +}, +{ +"host": "127.0.0.1", +"xff": "fe80::1%eth0", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "fe80::1%eth0" +}, +{ +"host": "::ffff:1.2.3.4%eth0", +"xff": null, +"tp": [], +"depth": 1, +"expected": "::ffff:1.2.3.4%eth0" +}, +{ +"host": "127.0.0.1", +"xff": "::ffff:1.2.3.4%eth0", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "::ffff:1.2.3.4%eth0" +}, +{ +"host": "::ffff:1.2.3.4%eth1", +"xff": null, +"tp": [], +"depth": 1, +"expected": "::ffff:1.2.3.4%eth1" +}, +{ +"host": "127.0.0.1", +"xff": "::ffff:1.2.3.4%eth1", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "::ffff:1.2.3.4%eth1" +}, +{ +"host": "FE80::A%Eth0", +"xff": null, +"tp": [], +"depth": 1, +"expected": "fe80::a%Eth0" +}, +{ +"host": "127.0.0.1", +"xff": "FE80::A%Eth0", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "fe80::a%Eth0" +}, +{ +"host": "unknown-host", +"xff": null, +"tp": [], +"depth": 1, +"expected": "unknown-host" +}, +{ +"host": "999.1.1.1", +"xff": null, +"tp": [], +"depth": 1, +"expected": "999.1.1.1" +}, +{ +"host": "01.2.3.4", +"xff": null, +"tp": [], +"depth": 1, +"expected": "01.2.3.4" +}, +{ +"host": "", +"xff": null, +"tp": [], +"depth": 1, +"expected": "unknown" +}, +{ +"host": "127.0.0.1", +"xff": "9.9.9.9, 1.2.3.4", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "127.0.0.1", +"xff": "9.9.9.9, 1.2.3.4", +"tp": [ +"127.0.0.1" +], +"depth": 2, +"expected": "1.2.3.4" +}, +{ +"host": "127.0.0.1", +"xff": "8.8.8.8, 9.9.9.9, 1.2.3.4", +"tp": [ +"127.0.0.1", +"1.2.3.4" +], +"depth": 2, +"expected": "9.9.9.9" +}, +{ +"host": "127.0.0.1", +"xff": "9.9.9.9", +"tp": [ +"127.0.0.1" +], +"depth": 2, +"expected": "127.0.0.1" +}, +{ +"host": "127.0.0.1", +"xff": "9.9.9.9, 1.2.3.4", +"tp": [ +"127.0.0.1" +], +"depth": 3, +"expected": "127.0.0.1" +}, +{ +"host": "127.0.0.1", +"xff": "1.2.3.4, ,5.6.7.8", +"tp": [ +"127.0.0.1" +], +"depth": 2, +"expected": "5.6.7.8" +}, +{ +"host": "127.0.0.1", +"xff": "1.2.3.4,", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "127.0.0.1" +}, +{ +"host": "127.0.0.1", +"xff": "1.2.3.4:8080", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "127.0.0.1", +"xff": "[2001:db8::1]:443", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "2001:db8::1" +}, +{ +"host": "127.0.0.1", +"xff": "[2001:db8::1]", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "2001:db8::1" +}, +{ +"host": "127.0.0.1", +"xff": "2001:db8::1:443", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "2001:db8::1:443" +}, +{ +"host": "127.0.0.1", +"xff": "evil, 1.2.3.4", +"tp": [ +"127.0.0.1" +], +"depth": 2, +"expected": "1.2.3.4" +}, +{ +"host": "127.0.0.1", +"xff": "1.2.3.4, evil", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "127.0.0.1" +}, +{ +"host": "127.0.0.1", +"xff": "1.2.3.*", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "127.0.0.1" +}, +{ +"host": "127.0.0.1", +"xff": "1.2.3.4?", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "127.0.0.1" +}, +{ +"host": "127.0.0.1", +"xff": "::ffff:5.6.7.8", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "5.6.7.8" +}, +{ +"host": "127.0.0.1", +"xff": "0x7f.1", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "127.0.0.1" +}, +{ +"host": "127.0.0.1", +"xff": "2130706433", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "127.0.0.1" +}, +{ +"host": "127.0.0.1", +"xff": "1.2.3.4", +"tp": [ +"127.0.0.0/8" +], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "127.5.5.5", +"xff": "1.2.3.4", +"tp": [ +"127.0.0.0/8" +], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "::ffff:127.0.0.1", +"xff": "1.2.3.4", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "127.0.0.1" +}, +{ +"host": "::ffff:127.0.0.1", +"xff": "1.2.3.4", +"tp": [ +"127.0.0.0/8" +], +"depth": 1, +"expected": "127.0.0.1" +}, +{ +"host": "::1", +"xff": "1.2.3.4", +"tp": [ +"::1" +], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "::1", +"xff": "1.2.3.4", +"tp": [ +"::/0" +], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "127.0.0.1", +"xff": "1.2.3.4, 10.0.0.2", +"tp": [ +"127.0.0.1", +"10.0.0.2" +], +"depth": 2, +"expected": "1.2.3.4" +}, +{ +"host": "127.0.0.1", +"xff": "1.2.3.4, 10.0.0.2, 10.0.0.3", +"tp": [ +"127.0.0.1", +"10.0.0.2", +"10.0.0.3" +], +"depth": 2, +"expected": "10.0.0.2" +}, +{ +"host": "127.0.0.1", +"xff": "1.2.3.4, 5.5.5.5, 10.0.0.3", +"tp": [ +"127.0.0.1", +"10.0.0.3" +], +"depth": 3, +"expected": "5.5.5.5" +}, +{ +"host": "127.0.0.1", +"xff": "1.2.3.4, 5.5.5.5, 10.0.0.3", +"tp": [ +"127.0.0.1", +"10.0.0.3" +], +"depth": 2, +"expected": "5.5.5.5" +}, +{ +"host": "127.0.0.1", +"xff": "10.0.0.2, 10.0.0.3", +"tp": [ +"127.0.0.1", +"10.0.0.2", +"10.0.0.3" +], +"depth": 1, +"expected": "10.0.0.3" +}, +{ +"host": "127.0.0.1", +"xff": "10.0.0.2", +"tp": [ +"127.0.0.1", +"10.0.0.2" +], +"depth": 1, +"expected": "10.0.0.2" +}, +{ +"host": "203.0.113.5", +"xff": "1.2.3.4", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "203.0.113.5" +}, +{ +"host": "203.0.113.5", +"xff": "1.2.3.4", +"tp": [], +"depth": 1, +"expected": "203.0.113.5" +}, +{ +"host": "203.0.113.5", +"xff": "1.2.3.4, 5.6.7.8", +"tp": [ +"10.0.0.0/8" +], +"depth": 2, +"expected": "203.0.113.5" +}, +{ +"host": "::ffff:203.0.113.5", +"xff": "1.2.3.4", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "203.0.113.5" +}, +{ +"host": "unknown-host", +"xff": "1.2.3.4", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "unknown-host" +}, +{ +"host": "[::1]", +"xff": "1.2.3.4", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "::1" +}, +{ +"host": null, +"xff": null, +"tp": [], +"depth": 1, +"expected": "unknown" +}, +{ +"host": null, +"xff": "1.2.3.4", +"tp": [], +"depth": 1, +"expected": "unknown" +}, +{ +"host": null, +"xff": "1.2.3.4", +"tp": [ +"unix" +], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": null, +"xff": "9.9.9.9, 1.2.3.4", +"tp": [ +"unix" +], +"depth": 2, +"expected": "1.2.3.4" +}, +{ +"host": null, +"xff": "evil", +"tp": [ +"unix" +], +"depth": 1, +"expected": "unknown" +}, +{ +"host": null, +"xff": null, +"tp": [ +"unix" +], +"depth": 1, +"expected": "unknown" +}, +{ +"host": null, +"xff": "9.9.9.9, 1.2.3.4", +"tp": [ +"unix", +"1.2.3.4" +], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "1.2.3.4:80", +"xff": null, +"tp": [], +"depth": 1, +"expected": "1.2.3.4:80" +}, +{ +"host": "[::1]:80", +"xff": null, +"tp": [], +"depth": 1, +"expected": "[::1]:80" +}, +{ +"host": "::ffff:127.0.0.1", +"xff": "1.2.3.4", +"tp": [ +"::ffff:127.0.0.1" +], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "127.0.0.1", +"xff": "1.2.3.4", +"tp": [ +"::ffff:127.0.0.1" +], +"depth": 1, +"expected": "127.0.0.1" +}, +{ +"host": "::ffff:127.0.0.1", +"xff": "1.2.3.4", +"tp": [ +"::ffff:127.0.0.0/104" +], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "127.0.0.1", +"xff": "::ffff:1.2.3.4, ::ffff:127.0.0.1", +"tp": [ +"127.0.0.1" +], +"depth": 2, +"expected": "1.2.3.4" +}, +{ +"host": "127.0.0.1", +"xff": "1.2.3.4, 10.0.0.2", +"tp": [ +"127.0.0.1", +"::ffff:10.0.0.2" +], +"depth": 1, +"expected": "10.0.0.2" +}, +{ +"host": "127.0.0.1", +"xff": "1.2.3.4", +"tp": [ +"not-an-ip", +"127.0.0.1" +], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "127.0.0.1", +"xff": "1.2.3.4", +"tp": [ +"10.0.0.0/99", +"127.0.0.1" +], +"depth": 1, +"expected": "127.0.0.1" +}, +{ +"host": "127.0.0.1", +"xff": "[1.2.3.4]", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "127.0.0.1", +"xff": "[::ffff:1.2.3.4]:80", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "127.0.0.1", +"xff": "1.2.3.4%eth0", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "127.0.0.1" +}, +{ +"host": "127.0.0.1", +"xff": "fe80::1%eth0", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "fe80::1%eth0" +}, +{ +"host": "fe80::1%eth0", +"xff": "1.2.3.4", +"tp": [ +"fe80::1%eth0" +], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "127.0.0.1", +"xff": " 1.2.3.4 ", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": "127.0.0.1", +"xff": "1.2.3.4,5.6.7.8,9.9.9.9", +"tp": [ +"127.0.0.1", +"9.9.9.9", +"5.6.7.8" +], +"depth": 3, +"expected": "1.2.3.4" +}, +{ +"host": "::ffff:7f00:1", +"xff": "1.2.3.4", +"tp": [ +"127.0.0.1" +], +"depth": 1, +"expected": "127.0.0.1" +}, +{ +"host": null, +"xff": "[::ffff:1.2.3.4]", +"tp": [ +"unix" +], +"depth": 1, +"expected": "1.2.3.4" +}, +{ +"host": null, +"xff": "10.0.0.2, 1.2.3.4", +"tp": [ +"unix", +"1.2.3.4" +], +"depth": 2, +"expected": "10.0.0.2" +} +] \ No newline at end of file diff --git a/tests/data/client_ip_lists.json b/tests/data/client_ip_lists.json new file mode 100644 index 0000000..35d1c49 --- /dev/null +++ b/tests/data/client_ip_lists.json @@ -0,0 +1,786 @@ +[ +{ +"ip": "10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"10.1.1.1" +], +"expected": true +}, +{ +"ip": "10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"10.0.0.0/8" +], +"expected": true +}, +{ +"ip": "10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"::ffff:10.1.1.1" +], +"expected": true +}, +{ +"ip": "10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"::ffff:10.0.0.0/104" +], +"expected": false +}, +{ +"ip": "10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"2001:0DB8:0:0:0:0:0:1" +], +"expected": false +}, +{ +"ip": "10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"2001:db8::/32" +], +"expected": false +}, +{ +"ip": "10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"fe80::1%eth0" +], +"expected": false +}, +{ +"ip": "10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"fe80::/10" +], +"expected": false +}, +{ +"ip": "10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"not-an-ip" +], +"expected": false +}, +{ +"ip": "10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"10.1.1.2", +"192.168.0.0/16" +], +"expected": false +}, +{ +"ip": "10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"[10.1.1.1]" +], +"expected": true +}, +{ +"ip": "10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"::ffff:10.1.1.1%eth0" +], +"expected": false +}, +{ +"ip": "10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"10.1.1.0/24", +"2001:db8::1" +], +"expected": true +}, +{ +"ip": "10.1.1.1", +"canonical": "10.1.1.1", +"entries": [], +"expected": false +}, +{ +"ip": "::ffff:10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"10.1.1.1" +], +"expected": true +}, +{ +"ip": "::ffff:10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"10.0.0.0/8" +], +"expected": true +}, +{ +"ip": "::ffff:10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"::ffff:10.1.1.1" +], +"expected": true +}, +{ +"ip": "::ffff:10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"::ffff:10.0.0.0/104" +], +"expected": false +}, +{ +"ip": "::ffff:10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"2001:0DB8:0:0:0:0:0:1" +], +"expected": false +}, +{ +"ip": "::ffff:10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"2001:db8::/32" +], +"expected": false +}, +{ +"ip": "::ffff:10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"fe80::1%eth0" +], +"expected": false +}, +{ +"ip": "::ffff:10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"fe80::/10" +], +"expected": false +}, +{ +"ip": "::ffff:10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"not-an-ip" +], +"expected": false +}, +{ +"ip": "::ffff:10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"10.1.1.2", +"192.168.0.0/16" +], +"expected": false +}, +{ +"ip": "::ffff:10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"[10.1.1.1]" +], +"expected": true +}, +{ +"ip": "::ffff:10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"::ffff:10.1.1.1%eth0" +], +"expected": false +}, +{ +"ip": "::ffff:10.1.1.1", +"canonical": "10.1.1.1", +"entries": [ +"10.1.1.0/24", +"2001:db8::1" +], +"expected": true +}, +{ +"ip": "::ffff:10.1.1.1", +"canonical": "10.1.1.1", +"entries": [], +"expected": false +}, +{ +"ip": "2001:db8::1", +"canonical": "2001:db8::1", +"entries": [ +"10.1.1.1" +], +"expected": false +}, +{ +"ip": "2001:db8::1", +"canonical": "2001:db8::1", +"entries": [ +"10.0.0.0/8" +], +"expected": false +}, +{ +"ip": "2001:db8::1", +"canonical": "2001:db8::1", +"entries": [ +"::ffff:10.1.1.1" +], +"expected": false +}, +{ +"ip": "2001:db8::1", +"canonical": "2001:db8::1", +"entries": [ +"::ffff:10.0.0.0/104" +], +"expected": false +}, +{ +"ip": "2001:db8::1", +"canonical": "2001:db8::1", +"entries": [ +"2001:0DB8:0:0:0:0:0:1" +], +"expected": true +}, +{ +"ip": "2001:db8::1", +"canonical": "2001:db8::1", +"entries": [ +"2001:db8::/32" +], +"expected": true +}, +{ +"ip": "2001:db8::1", +"canonical": "2001:db8::1", +"entries": [ +"fe80::1%eth0" +], +"expected": false +}, +{ +"ip": "2001:db8::1", +"canonical": "2001:db8::1", +"entries": [ +"fe80::/10" +], +"expected": false +}, +{ +"ip": "2001:db8::1", +"canonical": "2001:db8::1", +"entries": [ +"not-an-ip" +], +"expected": false +}, +{ +"ip": "2001:db8::1", +"canonical": "2001:db8::1", +"entries": [ +"10.1.1.2", +"192.168.0.0/16" +], +"expected": false +}, +{ +"ip": "2001:db8::1", +"canonical": "2001:db8::1", +"entries": [ +"[10.1.1.1]" +], +"expected": false +}, +{ +"ip": "2001:db8::1", +"canonical": "2001:db8::1", +"entries": [ +"::ffff:10.1.1.1%eth0" +], +"expected": false +}, +{ +"ip": "2001:db8::1", +"canonical": "2001:db8::1", +"entries": [ +"10.1.1.0/24", +"2001:db8::1" +], +"expected": true +}, +{ +"ip": "2001:db8::1", +"canonical": "2001:db8::1", +"entries": [], +"expected": false +}, +{ +"ip": "fe80::1%eth0", +"canonical": "fe80::1%eth0", +"entries": [ +"10.1.1.1" +], +"expected": false +}, +{ +"ip": "fe80::1%eth0", +"canonical": "fe80::1%eth0", +"entries": [ +"10.0.0.0/8" +], +"expected": false +}, +{ +"ip": "fe80::1%eth0", +"canonical": "fe80::1%eth0", +"entries": [ +"::ffff:10.1.1.1" +], +"expected": false +}, +{ +"ip": "fe80::1%eth0", +"canonical": "fe80::1%eth0", +"entries": [ +"::ffff:10.0.0.0/104" +], +"expected": false +}, +{ +"ip": "fe80::1%eth0", +"canonical": "fe80::1%eth0", +"entries": [ +"2001:0DB8:0:0:0:0:0:1" +], +"expected": false +}, +{ +"ip": "fe80::1%eth0", +"canonical": "fe80::1%eth0", +"entries": [ +"2001:db8::/32" +], +"expected": false +}, +{ +"ip": "fe80::1%eth0", +"canonical": "fe80::1%eth0", +"entries": [ +"fe80::1%eth0" +], +"expected": true +}, +{ +"ip": "fe80::1%eth0", +"canonical": "fe80::1%eth0", +"entries": [ +"fe80::/10" +], +"expected": true +}, +{ +"ip": "fe80::1%eth0", +"canonical": "fe80::1%eth0", +"entries": [ +"not-an-ip" +], +"expected": false +}, +{ +"ip": "fe80::1%eth0", +"canonical": "fe80::1%eth0", +"entries": [ +"10.1.1.2", +"192.168.0.0/16" +], +"expected": false +}, +{ +"ip": "fe80::1%eth0", +"canonical": "fe80::1%eth0", +"entries": [ +"[10.1.1.1]" +], +"expected": false +}, +{ +"ip": "fe80::1%eth0", +"canonical": "fe80::1%eth0", +"entries": [ +"::ffff:10.1.1.1%eth0" +], +"expected": false +}, +{ +"ip": "fe80::1%eth0", +"canonical": "fe80::1%eth0", +"entries": [ +"10.1.1.0/24", +"2001:db8::1" +], +"expected": false +}, +{ +"ip": "fe80::1%eth0", +"canonical": "fe80::1%eth0", +"entries": [], +"expected": false +}, +{ +"ip": "::ffff:1.2.3.4%eth0", +"canonical": "::ffff:1.2.3.4%eth0", +"entries": [ +"10.1.1.1" +], +"expected": false +}, +{ +"ip": "::ffff:1.2.3.4%eth0", +"canonical": "::ffff:1.2.3.4%eth0", +"entries": [ +"10.0.0.0/8" +], +"expected": false +}, +{ +"ip": "::ffff:1.2.3.4%eth0", +"canonical": "::ffff:1.2.3.4%eth0", +"entries": [ +"::ffff:10.1.1.1" +], +"expected": false +}, +{ +"ip": "::ffff:1.2.3.4%eth0", +"canonical": "::ffff:1.2.3.4%eth0", +"entries": [ +"::ffff:10.0.0.0/104" +], +"expected": false +}, +{ +"ip": "::ffff:1.2.3.4%eth0", +"canonical": "::ffff:1.2.3.4%eth0", +"entries": [ +"2001:0DB8:0:0:0:0:0:1" +], +"expected": false +}, +{ +"ip": "::ffff:1.2.3.4%eth0", +"canonical": "::ffff:1.2.3.4%eth0", +"entries": [ +"2001:db8::/32" +], +"expected": false +}, +{ +"ip": "::ffff:1.2.3.4%eth0", +"canonical": "::ffff:1.2.3.4%eth0", +"entries": [ +"fe80::1%eth0" +], +"expected": false +}, +{ +"ip": "::ffff:1.2.3.4%eth0", +"canonical": "::ffff:1.2.3.4%eth0", +"entries": [ +"fe80::/10" +], +"expected": false +}, +{ +"ip": "::ffff:1.2.3.4%eth0", +"canonical": "::ffff:1.2.3.4%eth0", +"entries": [ +"not-an-ip" +], +"expected": false +}, +{ +"ip": "::ffff:1.2.3.4%eth0", +"canonical": "::ffff:1.2.3.4%eth0", +"entries": [ +"10.1.1.2", +"192.168.0.0/16" +], +"expected": false +}, +{ +"ip": "::ffff:1.2.3.4%eth0", +"canonical": "::ffff:1.2.3.4%eth0", +"entries": [ +"[10.1.1.1]" +], +"expected": false +}, +{ +"ip": "::ffff:1.2.3.4%eth0", +"canonical": "::ffff:1.2.3.4%eth0", +"entries": [ +"::ffff:10.1.1.1%eth0" +], +"expected": false +}, +{ +"ip": "::ffff:1.2.3.4%eth0", +"canonical": "::ffff:1.2.3.4%eth0", +"entries": [ +"10.1.1.0/24", +"2001:db8::1" +], +"expected": false +}, +{ +"ip": "::ffff:1.2.3.4%eth0", +"canonical": "::ffff:1.2.3.4%eth0", +"entries": [], +"expected": false +}, +{ +"ip": "::ffff:0a01:0101", +"canonical": "10.1.1.1", +"entries": [ +"10.1.1.1" +], +"expected": true +}, +{ +"ip": "::ffff:0a01:0101", +"canonical": "10.1.1.1", +"entries": [ +"10.0.0.0/8" +], +"expected": true +}, +{ +"ip": "::ffff:0a01:0101", +"canonical": "10.1.1.1", +"entries": [ +"::ffff:10.1.1.1" +], +"expected": true +}, +{ +"ip": "::ffff:0a01:0101", +"canonical": "10.1.1.1", +"entries": [ +"::ffff:10.0.0.0/104" +], +"expected": false +}, +{ +"ip": "::ffff:0a01:0101", +"canonical": "10.1.1.1", +"entries": [ +"2001:0DB8:0:0:0:0:0:1" +], +"expected": false +}, +{ +"ip": "::ffff:0a01:0101", +"canonical": "10.1.1.1", +"entries": [ +"2001:db8::/32" +], +"expected": false +}, +{ +"ip": "::ffff:0a01:0101", +"canonical": "10.1.1.1", +"entries": [ +"fe80::1%eth0" +], +"expected": false +}, +{ +"ip": "::ffff:0a01:0101", +"canonical": "10.1.1.1", +"entries": [ +"fe80::/10" +], +"expected": false +}, +{ +"ip": "::ffff:0a01:0101", +"canonical": "10.1.1.1", +"entries": [ +"not-an-ip" +], +"expected": false +}, +{ +"ip": "::ffff:0a01:0101", +"canonical": "10.1.1.1", +"entries": [ +"10.1.1.2", +"192.168.0.0/16" +], +"expected": false +}, +{ +"ip": "::ffff:0a01:0101", +"canonical": "10.1.1.1", +"entries": [ +"[10.1.1.1]" +], +"expected": true +}, +{ +"ip": "::ffff:0a01:0101", +"canonical": "10.1.1.1", +"entries": [ +"::ffff:10.1.1.1%eth0" +], +"expected": false +}, +{ +"ip": "::ffff:0a01:0101", +"canonical": "10.1.1.1", +"entries": [ +"10.1.1.0/24", +"2001:db8::1" +], +"expected": true +}, +{ +"ip": "::ffff:0a01:0101", +"canonical": "10.1.1.1", +"entries": [], +"expected": false +}, +{ +"ip": "[::ffff:10.1.1.1]", +"canonical": "10.1.1.1", +"entries": [ +"10.1.1.1" +], +"expected": true +}, +{ +"ip": "[::ffff:10.1.1.1]", +"canonical": "10.1.1.1", +"entries": [ +"10.0.0.0/8" +], +"expected": true +}, +{ +"ip": "[::ffff:10.1.1.1]", +"canonical": "10.1.1.1", +"entries": [ +"::ffff:10.1.1.1" +], +"expected": true +}, +{ +"ip": "[::ffff:10.1.1.1]", +"canonical": "10.1.1.1", +"entries": [ +"::ffff:10.0.0.0/104" +], +"expected": false +}, +{ +"ip": "[::ffff:10.1.1.1]", +"canonical": "10.1.1.1", +"entries": [ +"2001:0DB8:0:0:0:0:0:1" +], +"expected": false +}, +{ +"ip": "[::ffff:10.1.1.1]", +"canonical": "10.1.1.1", +"entries": [ +"2001:db8::/32" +], +"expected": false +}, +{ +"ip": "[::ffff:10.1.1.1]", +"canonical": "10.1.1.1", +"entries": [ +"fe80::1%eth0" +], +"expected": false +}, +{ +"ip": "[::ffff:10.1.1.1]", +"canonical": "10.1.1.1", +"entries": [ +"fe80::/10" +], +"expected": false +}, +{ +"ip": "[::ffff:10.1.1.1]", +"canonical": "10.1.1.1", +"entries": [ +"not-an-ip" +], +"expected": false +}, +{ +"ip": "[::ffff:10.1.1.1]", +"canonical": "10.1.1.1", +"entries": [ +"10.1.1.2", +"192.168.0.0/16" +], +"expected": false +}, +{ +"ip": "[::ffff:10.1.1.1]", +"canonical": "10.1.1.1", +"entries": [ +"[10.1.1.1]" +], +"expected": true +}, +{ +"ip": "[::ffff:10.1.1.1]", +"canonical": "10.1.1.1", +"entries": [ +"::ffff:10.1.1.1%eth0" +], +"expected": false +}, +{ +"ip": "[::ffff:10.1.1.1]", +"canonical": "10.1.1.1", +"entries": [ +"10.1.1.0/24", +"2001:db8::1" +], +"expected": true +}, +{ +"ip": "[::ffff:10.1.1.1]", +"canonical": "10.1.1.1", +"entries": [], +"expected": false +} +] \ No newline at end of file