Raiju/tests/agent-net-smoke.php
2026-10-25 06:05:42 +03:00

418 lines
22 KiB
PHP

<?php
declare(strict_types=1);
// raijuNetSafeFetch(): address classification, host/URL rules, DNS pinning,
// manual redirects, streaming size limit and Content-Type allowlist.
// Network behaviour runs against a loopback fixture server; resolution is injected.
$root = dirname(__DIR__);
require_once $root . '/includes/ai/agent/net.php';
require_once $root . '/includes/ai/ai-helper.php';
require_once $root . '/includes/ai/copilot-context.php';
function netAssert(bool $condition, string $message): void {
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
function netRejects(callable $call, string $reason, string $message): void {
try {
$call();
} catch (RaijuNetException $e) {
netAssert($e->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('<p>hello</p>');
};
$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'] === '<p>hello</p>' && $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'] === '<p>ok</p>' && $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'] === '<p>ok</p>' && count($viaRedirect['redirects']) === 1 && str_ends_with($viaRedirect['url'], '/ok'), 'relative redirect is followed manually');
netAssert($fetch('/redirect-absolute')['body'] === '<p>ok</p>', 'absolute redirect to the same public host');
netAssert($fetch('/chain/2')['body'] === '<p>ok</p>' && 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'] === '<p>ok</p>' && $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'] === '<p>ok</p>' && $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";