Skip to content
Merged
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
23 changes: 13 additions & 10 deletions .github/coverage-unreachable.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
252 changes: 252 additions & 0 deletions bin/test_client_ip.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
<?php

declare(strict_types=1);

use RenzoFranceschini\GuardCore\Config\SecurityConfig;
use RenzoFranceschini\GuardCore\Engine\GuardEngine;
use RenzoFranceschini\GuardCore\Events\SecurityEvent;
use RenzoFranceschini\GuardCore\Ip\CanonicalIp;
use RenzoFranceschini\GuardCore\Logging\SimpleRequestLogger;
use RenzoFranceschini\GuardCore\Request\ClientIpResolver;
use RenzoFranceschini\GuardCore\Request\SimpleGuardRequest;

require __DIR__ . '/../vendor/autoload.php';

// Client IP resolution parity with the reference engine (guard-core Python,
// guard_core/_utils/ip_extraction.py and _utils/access_control.py). The
// tables under tests/data/ are generated by running the reference
// extract_client_ip and _ip_in_list over each input, never hand-written.
// Runs in-memory (no Redis, no network).

final class T
{
public int $passed = 0;

public int $failed = 0;

private string $section = '';

public function section(string $name): void
{
$this->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<array<string, mixed>> */
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'));
2 changes: 1 addition & 1 deletion bin/test_state.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading