reason === $reason, "{$message} (expected {$reason}, got {$e->reason}: {$e->getMessage()})"); return; } fwrite(STDERR, "FAIL: {$message} (no exception)\n"); exit(1); } const PUBLIC_IP = '93.184.216.34'; $publicResolver = static fn(string $host): array => [PUBLIC_IP]; // --- Address classification --------------------------------------------------- $private = [ '0.0.0.0', '10.1.2.3', '100.64.0.1', '100.127.255.255', '127.0.0.1', '127.255.255.254', '169.254.169.254', '172.16.0.1', '172.31.255.255', '192.0.0.8', '192.0.2.1', '192.168.1.1', '198.18.0.1', '198.19.255.255', '198.51.100.7', '203.0.113.9', '224.0.0.1', '239.255.255.255', '240.0.0.1', '255.255.255.255', '::', '::1', '::ffff:127.0.0.1', '::ffff:8.8.8.8', '::127.0.0.1', '64:ff9b::808:808', '2002:7f00:1::', '2002:a9fe:a9fe::', 'fc00::1', 'fd12:3456::1', 'fe80::1', 'febf::1', '2001:db8::1', 'ff02::1', ]; foreach ($private as $ip) { netAssert(!raijuNetIsPublicIp($ip), "{$ip} must not be public"); } $public = [ '8.8.8.8', '93.184.216.34', '100.63.255.255', '100.128.0.1', '172.15.255.255', '172.32.0.1', '192.0.1.1', '192.169.0.1', '198.17.255.255', '198.20.0.1', '223.255.255.255', '2606:4700:4700::1111', '2a00:1450:4001::200e', '2002:5db8:d822::', ]; foreach ($public as $ip) { netAssert(raijuNetIsPublicIp($ip), "{$ip} must be public"); } netAssert(!raijuNetIsPublicIp('not-an-ip') && !raijuNetIsPublicIp(''), 'non-addresses are not public'); // Legacy numeric notations netAssert(raijuNetParseLegacyIpv4('2130706433') === '127.0.0.1', 'decimal integer'); netAssert(raijuNetParseLegacyIpv4('0x7f.1') === '127.0.0.1', 'hex with short form'); netAssert(raijuNetParseLegacyIpv4('0177.0.0.1') === '127.0.0.1', 'octal'); netAssert(raijuNetParseLegacyIpv4('127.1') === '127.0.0.1', 'short form'); netAssert(raijuNetParseLegacyIpv4('example.com') === null && raijuNetParseLegacyIpv4('08.1') === null, 'names and invalid octal'); // --- URL and host rules (no network: nothing here may reach the transport) ----- $noTransport = static function (): void { fwrite(STDERR, "FAIL: a rejected URL reached the transport\n"); exit(1); }; $opts = ['resolver' => $publicResolver, 'testTransport' => $noTransport]; $cases = [ ['http://127.0.0.1/', 'blocked_address'], ['http://169.254.169.254/latest/meta-data/', 'blocked_address'], ['http://[::1]/', 'blocked_address'], ['http://[::ffff:127.0.0.1]/', 'blocked_address'], ['http://[::ffff:7f00:1]/', 'blocked_address'], ['http://[2002:7f00:1::]/', 'blocked_address'], ['http://[fe80::1]/', 'blocked_address'], ['http://0.0.0.0/', 'blocked_address'], ['http://10.0.0.1/', 'blocked_address'], ['http://2130706433/', 'invalid_host'], ['http://0x7f.0.0.1/', 'invalid_host'], ['http://0177.0.0.1/', 'invalid_host'], ['http://127.1/', 'invalid_host'], ['http://localhost/', 'blocked_host'], ['http://LOCALHOST./', 'blocked_host'], ['http://service.localhost/', 'blocked_host'], ['http://intranet/', 'blocked_host'], ['ftp://example.com/file', 'scheme'], ['file:///etc/passwd', 'invalid_url'], ['gopher://example.com/', 'scheme'], ['http://user:secret@example.com/', 'credentials'], ['http://example.com@127.0.0.1/', 'credentials'], ['http://example.com:8080/', 'port'], ['https://example.com:22/', 'port'], ['http://example.com\\@127.0.0.1/', 'invalid_url'], ["http://example.com/\r\nHost: evil", 'invalid_url'], ['http://exa mple.com/', 'invalid_url'], ['', 'invalid_url'], ['/relative/path', 'invalid_url'], ['http://' . str_repeat('a', 300) . '.example/', 'invalid_host'], ]; foreach ($cases as [$url, $reason]) { netRejects(fn() => raijuNetSafeFetch($url, $opts), $reason, "URL {$url}"); } // Resolution results foreach ([ [['10.0.0.5'], 'blocked_address'], [['127.0.0.1'], 'blocked_address'], [['::1'], 'blocked_address'], [['::ffff:127.0.0.1'], 'blocked_address'], [[PUBLIC_IP, '10.0.0.5'], 'blocked_address'], [['2606:4700:4700::1111', '169.254.169.254'], 'blocked_address'], [[], 'unresolved'], ] as [$ips, $reason]) { netRejects(fn() => raijuNetSafeFetch('http://rebind.test/', ['resolver' => static fn() => $ips, 'testTransport' => $noTransport]), $reason, 'resolver answer ' . json_encode($ips)); } if (function_exists('idn_to_ascii')) { $prepared = raijuNetPrepareUrl('http://BÜCHER.example/Pfad?q=1#frag', ['resolver' => $publicResolver]); netAssert($prepared['host'] === 'xn--bcher-kva.example' && $prepared['url'] === 'http://xn--bcher-kva.example/Pfad?q=1', 'IDN host is converted to ASCII, fragment dropped'); } $prepared = raijuNetPrepareUrl('HTTPS://Example.COM./a/b', ['resolver' => $publicResolver]); netAssert($prepared['url'] === 'https://example.com/a/b' && $prepared['port'] === 443 && $prepared['ips'] === [PUBLIC_IP], 'host normalised: case, trailing dot, default port'); // IPv4 first when both families come back $prepared = raijuNetPrepareUrl('http://dual.test/', ['resolver' => static fn() => ['2606:4700:4700::1111', PUBLIC_IP]]); netAssert($prepared['ips'][0] === PUBLIC_IP, 'IPv4 addresses are tried first'); // Test overrides must not exist outside the CLI SAPIs (guard documented in net.php). netAssert(in_array(PHP_SAPI, ['cli', 'cli-server'], true), 'tests run under CLI'); // --- Redirect resolution ------------------------------------------------------ $base = 'http://example.com/a/b/c.html?x=1'; foreach ([ ['/root', 'http://example.com/root'], ['sibling.html', 'http://example.com/a/b/sibling.html'], ['../up.html', 'http://example.com/a/up.html'], ['../../../../way/up', 'http://example.com/way/up'], ['?q=2', 'http://example.com/a/b/c.html?q=2'], ['//other.test/x', 'http://other.test/x'], ['https://secure.test/y', 'https://secure.test/y'], ['./here/', 'http://example.com/a/b/here/'], ] as [$location, $expected]) { netAssert(raijuNetResolveRedirect($base, $location) === $expected, "redirect {$location} -> " . raijuNetResolveRedirect($base, $location)); } netRejects(fn() => raijuNetResolveRedirect($base, ''), 'redirect', 'empty Location'); // --- DNS pinning and streaming contract with an injected transport ------------ $resolutions = []; $rebinding = static function (string $host) use (&$resolutions): array { $resolutions[] = $host; return count($resolutions) === 1 ? [PUBLIC_IP] : ['127.0.0.1']; // the second answer is an attack }; $seen = null; $transport = static function (array $request, callable $onHeader, callable $onData) use (&$seen): void { $seen = $request; $onHeader("HTTP/1.1 200 OK\r\n"); $onHeader("Content-Type: text/html; charset=utf-8\r\n"); $onHeader("\r\n"); $onData('
hello
'); }; $response = raijuNetSafeFetch('http://rebind.example/page', ['resolver' => $rebinding, 'testTransport' => $transport]); netAssert(count($resolutions) === 1, 'the host is resolved exactly once per hop'); netAssert($seen['ips'] === [PUBLIC_IP] && $seen['dial'] === [PUBLIC_IP] && $seen['host'] === 'rebind.example' && $seen['port'] === 80, 'the validated address is pinned, later DNS answers are ignored'); netAssert($response['body'] === 'hello
' && $response['contentType'] === 'text/html' && $response['ip'] === PUBLIC_IP, 'response is returned'); // Streaming: the transfer is aborted at the first chunk that crosses the limit. $delivered = 0; $streaming = static function (array $request, callable $onHeader, callable $onData) use (&$delivered): void { $onHeader("HTTP/1.1 200 OK\r\n"); $onHeader("Content-Type: text/html\r\n"); $onHeader("\r\n"); for ($i = 0; $i < 40; $i++) { $delivered++; if ($onData(str_repeat('x', 256 * 1024)) === false) { return; // like curl: stop when the write callback signals an error } } }; netRejects(fn() => raijuNetSafeFetch('http://big.test/', ['resolver' => $publicResolver, 'testTransport' => $streaming]), 'too_large', 'streamed 10 MB body against the 2 MB default'); netAssert($delivered === 9, "body streaming stopped at the limit ({$delivered} chunks)"); $small = raijuNetSafeFetch('http://big.test/', ['resolver' => $publicResolver, 'testTransport' => $streaming, 'maxBytes' => 12 * 1024 * 1024]); netAssert(strlen($small['body']) === 40 * 256 * 1024, 'a higher limit lets the same body through'); // Content-Length above the limit aborts before the body $bodyReads = 0; $declared = static function (array $request, callable $onHeader, callable $onData) use (&$bodyReads): void { $onHeader("HTTP/1.1 200 OK\r\n"); $onHeader("Content-Type: text/html\r\n"); $onHeader("Content-Length: 3145728\r\n"); if ($onHeader("\r\n") === false) { return; } $bodyReads++; $onData('x'); }; netRejects(fn() => raijuNetSafeFetch('http://big.test/', ['resolver' => $publicResolver, 'testTransport' => $declared]), 'too_large', 'declared 3 MB body'); netAssert($bodyReads === 0, 'declared size is rejected from the headers alone'); // Content-Type allowlist $typed = static fn(string $type) => static function (array $request, callable $onHeader, callable $onData) use ($type): void { $onHeader("HTTP/1.1 200 OK\r\n"); if ($type !== '') $onHeader("Content-Type: {$type}\r\n"); if ($onHeader("\r\n") !== false) $onData('payload'); }; foreach (['application/pdf', 'image/svg+xml', 'application/octet-stream', 'text/html-evil', ''] as $type) { netRejects(fn() => raijuNetSafeFetch('http://t.test/', ['resolver' => $publicResolver, 'testTransport' => $typed($type)]), 'content_type', 'content type "' . $type . '"'); } foreach (['text/html', 'TEXT/HTML; charset=UTF-8', 'application/xhtml+xml', 'text/plain;charset=utf-8'] as $type) { netAssert(raijuNetSafeFetch('http://t.test/', ['resolver' => $publicResolver, 'testTransport' => $typed($type)])['body'] === 'payload', "content type {$type} accepted"); } netAssert(raijuNetSafeFetch('http://t.test/', ['resolver' => $publicResolver, 'testTransport' => $typed('image/webp'), 'allowedContentTypes' => ['image/webp']])['body'] === 'payload', 'custom allowlist'); // Non-2xx and missing response $statusOnly = static fn(int $code) => static function (array $request, callable $onHeader, callable $onData) use ($code): void { $onHeader("HTTP/1.1 {$code} X\r\n"); $onHeader("Content-Type: text/html\r\n"); $onHeader("\r\n"); $onData(str_repeat('e', 3 * 1024 * 1024)); }; try { raijuNetSafeFetch('http://t.test/', ['resolver' => $publicResolver, 'testTransport' => $statusOnly(500)]); netAssert(false, 'HTTP 500 must fail'); } catch (RaijuNetException $e) { netAssert($e->reason === 'http_status' && $e->httpStatus === 500, 'error pages report their status, not a size violation'); } netRejects(fn() => raijuNetSafeFetch('http://t.test/', ['resolver' => $publicResolver, 'testTransport' => static function (): void {}]), 'transport', 'no response'); // Redirects through the transport: every hop is re-validated and re-resolved $hops = []; $redirecting = static function (array $request, callable $onHeader, callable $onData) use (&$hops): void { $hops[] = $request['url']; if ($request['url'] === 'http://start.test/') { $onHeader("HTTP/1.1 302 Found\r\n"); $onHeader("Location: http://second.test/next\r\n"); $onHeader("\r\n"); return; } $onHeader("HTTP/1.1 200 OK\r\n"); $onHeader("Content-Type: text/plain\r\n"); $onHeader("\r\n"); $onData('done'); }; $resolved = []; $perHost = static function (string $host) use (&$resolved): array { $resolved[] = $host; return $host === 'second.test' ? ['198.51.100.1'] : [PUBLIC_IP]; // 198.51.100.0/24 is documentation space }; netRejects(fn() => raijuNetSafeFetch('http://start.test/', ['resolver' => $perHost, 'testTransport' => $redirecting]), 'blocked_address', 'redirect to a host resolving to a non-public address'); netAssert($hops === ['http://start.test/'] && $resolved === ['start.test', 'second.test'], 'the second hop was resolved and refused before any request'); // --- Real curl against the loopback fixture ------------------------------------ if (!function_exists('curl_init')) { echo "SKIP curl transport: curl extension missing\n"; echo "PASS agent net smoke (without curl)\n"; exit(0); } function netStartServer(string $address, string $router, ?string &$error = null) { $workers = ['PHP_CLI_SERVER_WORKERS' => '4'] + getenv(); $process = proc_open([PHP_BINARY, '-S', $address, $router], [1 => ['file', '/dev/null', 'w'], 2 => ['file', '/dev/null', 'w']], $pipes, dirname($router), $workers); if (!is_resource($process)) { $error = 'cannot start php -S'; return null; } [$host, $port] = [substr($address, 0, strrpos($address, ':')), (int)substr($address, strrpos($address, ':') + 1)]; $stream = null; for ($i = 0; $i < 100 && !$stream; $i++) { $stream = @stream_socket_client('tcp://' . $host . ':' . $port, $errno, $errstr, 0.2); if (!$stream) usleep(50000); } if (!$stream) { proc_terminate($process); $error = 'server did not start'; return null; } fclose($stream); return $process; } function netFreePort(string $host = '127.0.0.1'): int { $server = stream_socket_server('tcp://' . $host . ':0', $errno, $errstr); $name = stream_socket_get_name($server, false); fclose($server); return (int)substr($name, strrpos($name, ':') + 1); } $port = netFreePort(); // Serve a throw-away copy of the fixture: never run a server out of the source tree. $serverDir = sys_get_temp_dir() . '/raiju-net-smoke-' . bin2hex(random_bytes(4)); mkdir($serverDir, 0700, true); $router = $serverDir . '/net-server.php'; copy(__DIR__ . '/fixtures/agent/net-server.php', $router); register_shutdown_function(static function () use ($router, $serverDir): void { @unlink($router); @rmdir($serverDir); }); $server = netStartServer('127.0.0.1:' . $port, $router, $startError); netAssert($server !== null, 'fixture server: ' . ($startError ?? '')); try { $resolverMap = static fn(string $host): array => match ($host) { 'internal.test' => ['10.0.0.5'], default => [PUBLIC_IP], }; $live = [ 'resolver' => $resolverMap, 'testAllowedPorts' => [$port, 80, 443], 'testConnectMap' => [PUBLIC_IP => '127.0.0.1'], // dial the fixture, validate against the public address 'timeout' => 10, ]; $fetch = static fn(string $path, array $extra = []) => raijuNetSafeFetch('http://public.test:' . $port . $path, $extra + $live); $ok = $fetch('/ok'); netAssert($ok['status'] === 200 && $ok['body'] === 'ok
' && $ok['contentType'] === 'text/html', 'plain GET through the pinned connection'); netAssert($fetch('/host')['body'] === 'public.test:' . $port, 'the Host header keeps the original name while the pinned address is dialled'); $cookieProbe = $fetch('/cookie'); netAssert($fetch('/cookie')['body'] === 'none' && $cookieProbe['body'] === 'none', 'cookies are never replayed'); netRejects(fn() => $fetch('/pdf'), 'content_type', 'application/pdf over HTTP'); netRejects(fn() => $fetch('/no-type'), 'content_type', 'missing Content-Type over HTTP'); netRejects(fn() => $fetch('/big-length'), 'too_large', '3 MB body with Content-Length'); netRejects(fn() => $fetch('/big-stream'), 'too_large', '3 MB body without Content-Length'); $image = $fetch('/image', ['allowedContentTypes' => ['image/png']]); netAssert(str_starts_with($image['body'], "\x89PNG"), 'allowed image type passes'); try { $fetch('/gone'); netAssert(false, '404 must fail'); } catch (RaijuNetException $e) { netAssert($e->reason === 'http_status' && $e->httpStatus === 404, '404 with a 3 MB error page reports the status'); } // Redirects $viaRedirect = $fetch('/redirect-ok'); netAssert($viaRedirect['body'] === 'ok
' && count($viaRedirect['redirects']) === 1 && str_ends_with($viaRedirect['url'], '/ok'), 'relative redirect is followed manually'); netAssert($fetch('/redirect-absolute')['body'] === 'ok
', 'absolute redirect to the same public host'); netAssert($fetch('/chain/2')['body'] === 'ok
' && count($fetch('/chain/2')['redirects']) === 3, 'three redirects are allowed'); netRejects(fn() => $fetch('/chain/3'), 'redirect_limit', 'four redirects'); netRejects(fn() => $fetch('/redirect-loop'), 'redirect_limit', 'redirect loop'); netRejects(fn() => $fetch('/redirect-loopback'), 'blocked_address', 'redirect to 127.0.0.1'); netRejects(fn() => $fetch('/redirect-metadata'), 'blocked_address', 'redirect to the cloud metadata address'); netRejects(fn() => $fetch('/redirect-ipv6'), 'blocked_address', 'redirect to an IPv4-mapped IPv6 loopback'); netRejects(fn() => $fetch('/redirect-internal-name'), 'blocked_address', 'redirect to a name resolving to 10.0.0.5'); netRejects(fn() => $fetch('/redirect-scheme'), 'scheme', 'redirect to ftp://'); netRejects(fn() => $fetch('/chain/3', ['maxRedirects' => 9]), 'redirect_limit', 'a caller cannot raise the redirect cap above 3'); // DNS rebinding with real sockets: validation sees the public address once; the // connection must land on the pinned (mapped) address even if DNS later says 127.0.0.1. $calls = 0; $rebind = static function (string $host) use (&$calls): array { return ++$calls === 1 ? [PUBLIC_IP] : ['127.0.0.1']; }; $pinned = raijuNetSafeFetch('http://rebind.test:' . $port . '/ok', ['resolver' => $rebind] + $live); netAssert($pinned['body'] === 'ok
' && $pinned['ip'] === PUBLIC_IP && $calls === 1, 'rebinding: pinned address wins, single resolution'); // IPv4 and IPv6 literals as hosts are checked, not looked up netRejects(fn() => raijuNetSafeFetch('http://127.0.0.1:' . $port . '/ok', $live), 'blocked_address', 'loopback literal even on the fixture port'); // curl must refuse to talk to anything but the pinned address: a dead mapping fails, it does not fall back to DNS netRejects(fn() => raijuNetSafeFetch('http://public.test:' . $port . '/ok', ['testConnectMap' => [PUBLIC_IP => '127.0.0.2']] + $live), 'transport', 'unreachable pinned address (no fallback to a second lookup)'); } finally { proc_terminate($server); proc_close($server); } // IPv6 pinning (CURLOPT_RESOLVE with a bracketed address) when the host has ::1 $probe = @stream_socket_server('tcp://[::1]:0', $errno, $errstr); if ($probe) { $name = stream_socket_get_name($probe, false); fclose($probe); $port6 = (int)substr($name, strrpos($name, ':') + 1); $server6 = netStartServer('[::1]:' . $port6, $router, $startError6); if ($server6 !== null) { try { $global6 = '2606:4700:4700::1111'; $response = raijuNetSafeFetch('http://six.test:' . $port6 . '/ok', [ 'resolver' => static fn() => [$global6], 'testAllowedPorts' => [$port6], 'testConnectMap' => [$global6 => '::1'], 'timeout' => 10, ]); netAssert($response['body'] === 'ok
' && $response['ip'] === $global6, 'IPv6 address pinned and dialled'); } finally { proc_terminate($server6); proc_close($server6); } } else { echo "SKIP IPv6 pinning: {$startError6}\n"; } } else { echo "SKIP IPv6 pinning: no IPv6 loopback\n"; } // --- Callers: Copilot reference images and the AI host check ------------------- foreach (['http://localhost/image.jpg', 'http://127.0.0.1/i.jpg', 'http://192.168.1.10/i.jpg', 'http://[::1]/i.jpg', 'http://2130706433/i.jpg', 'http://169.254.169.254/i.jpg'] as $url) { try { raijuCopilotDownloadExternalReferenceImage($url); netAssert(false, "reference image download must refuse {$url}"); } catch (RuntimeException $e) { netAssert(!($e instanceof RaijuNetException) && $e->getPrevious() instanceof RaijuNetException, "refusal of {$url} is reported as RuntimeException with the network cause"); } } foreach (['localhost', 'LOCALHOST', 'app.localhost', '127.0.0.1', '127.1', '[::1]', '::1', '::ffff:127.0.0.1', '2130706433', '0x7f.0.0.1', '10.0.0.8', '192.168.0.5', '169.254.169.254', 'fe80::1', 'fd00::1', '172.20.1.1'] as $host) { netAssert(raijuAiIsPrivateHost($host), "{$host} is a private host"); } foreach (['8.8.8.8', '93.184.216.34', '2606:4700:4700::1111', '', 'definitely-not-registered.invalid'] as $host) { netAssert(!raijuAiIsPrivateHost($host), "'{$host}' is not reported private"); } netAssert(raijuAiIsLocalBaseUrl('http://[::ffff:127.0.0.1]:11434/v1') && raijuAiIsLocalBaseUrl('http://2130706433:1234/v1'), 'local base URLs in IPv6-mapped and decimal notation are detected'); try { raijuAiValidateBaseUrl('http://[::ffff:10.0.0.1]/v1', false); netAssert(false, 'private provider URL must need the explicit local setting'); } catch (RuntimeException $e) { } raijuAiValidateBaseUrl('http://[::ffff:10.0.0.1]/v1', true); echo "PASS agent net: CIDR list (IPv4/IPv6), URL rules, DNS pinning, manual redirects, streaming limit, content types, callers\n";