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

573 lines
24 KiB
PHP

<?php
/**
* Hardened outbound HTTP for server-side fetches of user- or model-supplied URLs.
*
* raijuNetSafeFetch() only talks to public internet addresses:
* - http/https on ports 80/443, no credentials in the URL, IDN converted to ASCII;
* - every A/AAAA record is checked against an explicit CIDR list (PHP 8.1 has no
* FILTER_FLAG_GLOBAL_RANGE); one non-public address rejects the host;
* - the checked addresses are pinned with CURLOPT_RESOLVE, so a second DNS answer
* (rebinding) can never redirect the connection;
* - redirects are followed manually (max. 3) and every hop is validated again;
* - the body size is enforced while streaming, the Content-Type against an allowlist;
* - no cookies, no environment proxies (a proxy would resolve names itself).
*/
if (!class_exists('RaijuNetException', false)) {
class RaijuNetException extends RuntimeException {
public string $reason;
public int $httpStatus;
public function __construct(string $reason, string $message, int $httpStatus = 0) {
parent::__construct($message);
$this->reason = $reason;
$this->httpStatus = $httpStatus;
}
}
}
const RAIJU_NET_MAX_REDIRECTS = 3;
const RAIJU_NET_MAX_URL_LENGTH = 2048;
const RAIJU_NET_DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
const RAIJU_NET_DEFAULT_CONTENT_TYPES = ['text/html', 'application/xhtml+xml', 'text/plain'];
/**
* Test-only network redirection. RAIJU_AGENT_TEST_NET (JSON) lets the test suite point the
* outbound features at loopback mocks: apiBase / braveBase (base URLs), resolve {host: [ip]},
* ports, connectMap {ip: ip}. It only works under the CLI and the built-in server.
*
* @return array{resolve:array,options:array,raw:array}|null
*/
function raijuNetTestConfig(): ?array {
$raw = in_array(PHP_SAPI, ['cli', 'cli-server'], true) ? getenv('RAIJU_AGENT_TEST_NET') : false;
$test = is_string($raw) && $raw !== '' ? json_decode($raw, true) : null;
if (!is_array($test)) return null;
$resolve = (array)($test['resolve'] ?? []);
return [
'raw' => $test,
'resolve' => $resolve,
'options' => [
'resolver' => static fn(string $host): array => (array)($resolve[$host] ?? []),
'testAllowedPorts' => array_map('intval', (array)($test['ports'] ?? [])),
'testConnectMap' => (array)($test['connectMap'] ?? []),
],
];
}
/** IPv4 ranges that are not publicly routable, as [network, prefix length]. */
function raijuNetBlockedIpv4Ranges(): array {
return [
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['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],
['224.0.0.0', 4], ['240.0.0.0', 4],
];
}
/**
* IPv6 ranges that are not publicly routable. 2002::/16 (6to4) is handled
* separately because it is only as public as the IPv4 address embedded in it.
*/
function raijuNetBlockedIpv6Ranges(): array {
return [
['::', 96], // unspecified, loopback and IPv4-compatible addresses
['::ffff:0:0', 96], // IPv4-mapped
['64:ff9b::', 96], // NAT64
['64:ff9b:1::', 48], // local-use NAT64
['100::', 64], // discard-only
['2001::', 32], // Teredo
['2001:db8::', 32], // documentation
['fc00::', 7], // unique local
['fe80::', 10], // link-local
['fec0::', 10], // deprecated site-local
['ff00::', 8], // multicast
];
}
function raijuNetBinaryInRange(string $binary, string $network, int $prefix): bool {
$networkBinary = inet_pton($network);
if ($networkBinary === false || strlen($networkBinary) !== strlen($binary)) {
return false;
}
$fullBytes = intdiv($prefix, 8);
if ($fullBytes > 0 && substr($binary, 0, $fullBytes) !== substr($networkBinary, 0, $fullBytes)) {
return false;
}
$remainder = $prefix % 8;
if ($remainder === 0) {
return true;
}
$mask = (0xFF << (8 - $remainder)) & 0xFF;
return (ord($binary[$fullBytes]) & $mask) === (ord($networkBinary[$fullBytes]) & $mask);
}
/** True for a literal IPv4/IPv6 address that is globally routable. */
function raijuNetIsPublicIp(string $ip): bool {
$ip = trim($ip);
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
return false;
}
$binary = inet_pton($ip);
if ($binary === false) {
return false;
}
if (strlen($binary) === 4) {
foreach (raijuNetBlockedIpv4Ranges() as [$network, $prefix]) {
if (raijuNetBinaryInRange($binary, $network, $prefix)) {
return false;
}
}
return true;
}
foreach (raijuNetBlockedIpv6Ranges() as [$network, $prefix]) {
if (raijuNetBinaryInRange($binary, $network, $prefix)) {
return false;
}
}
if (raijuNetBinaryInRange($binary, '2002::', 16)) {
// 6to4 embeds the IPv4 address in bytes 2-5.
return raijuNetIsPublicIp(inet_ntop(substr($binary, 2, 4)) ?: '0.0.0.0');
}
return true;
}
/**
* Legacy inet_aton notation (2130706433, 0x7f.1, 0177.0.0.1, 127.1). Browsers, curl
* and libc accept these as IPv4 addresses, so they must never reach a hostname check.
*/
function raijuNetParseLegacyIpv4(string $host): ?string {
if ($host === '' || !preg_match('/^(0x[0-9a-f]*|[0-9]+)(\.(0x[0-9a-f]*|[0-9]+)){0,3}$/i', $host)) {
return null;
}
$parts = explode('.', $host);
$numbers = [];
foreach ($parts as $part) {
if (stripos($part, '0x') === 0) {
$digits = substr($part, 2);
$value = $digits === '' ? 0 : (preg_match('/^[0-9a-f]+$/i', $digits) ? hexdec($digits) : null);
} elseif (strlen($part) > 1 && $part[0] === '0') {
$value = preg_match('/^[0-7]+$/', $part) ? octdec($part) : null;
} else {
$value = (int)$part;
}
if ($value === null || $value < 0 || $value > 0xFFFFFFFF) {
return null;
}
$numbers[] = $value;
}
$last = array_pop($numbers);
$count = count($numbers);
if ($last >= 256 ** (4 - $count)) {
return null;
}
$address = $last;
foreach ($numbers as $index => $number) {
if ($number > 255) {
return null;
}
$address += $number * (256 ** (3 - $index));
}
return long2ip((int)$address) ?: null;
}
/** Normalised lowercase ASCII host without brackets or trailing dot, or null if unusable. */
function raijuNetNormalizeHost(string $host): ?string {
$host = trim($host);
if (str_starts_with($host, '[') && str_ends_with($host, ']')) {
$host = substr($host, 1, -1);
return filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? strtolower($host) : null;
}
$host = strtolower(rtrim($host, '.'));
if ($host === '' || strlen($host) > 253 || str_contains($host, '%')) {
return null;
}
if (preg_match('/[^\x20-\x7E]/', $host)) {
if (!function_exists('idn_to_ascii')) {
return null;
}
$ascii = @idn_to_ascii($host, IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46);
if (!is_string($ascii) || $ascii === '') {
return null;
}
$host = strtolower($ascii);
}
return preg_match('/^[a-z0-9_]([a-z0-9_.-]*[a-z0-9_])?$/', $host) ? $host : null;
}
/** Default resolver: DNS A and AAAA records, falling back to the system resolver. */
function raijuNetResolveHost(string $host): array {
$ips = [];
if (function_exists('dns_get_record')) {
$records = @dns_get_record($host, DNS_A | DNS_AAAA);
if (is_array($records)) {
foreach ($records as $record) {
if (!empty($record['ip'])) $ips[] = (string)$record['ip'];
if (!empty($record['ipv6'])) $ips[] = (string)$record['ipv6'];
}
}
}
if (!$ips) {
$resolved = @gethostbynamel($host);
if (is_array($resolved)) {
$ips = array_merge($ips, $resolved);
}
}
return array_values(array_unique(array_filter($ips, 'is_string')));
}
/**
* Validate a URL and resolve its host exactly once.
*
* @return array{url:string,scheme:string,host:string,port:int,path:string,ips:string[]}
*/
function raijuNetPrepareUrl(string $url, array $options = []): array {
$url = trim($url);
if ($url === '' || strlen($url) > RAIJU_NET_MAX_URL_LENGTH || preg_match('/[\x00-\x20\x7F\\\\]/', $url)) {
throw new RaijuNetException('invalid_url', 'Invalid URL.');
}
$url = explode('#', $url, 2)[0];
// parse_url() mangles uppercase non-ASCII bytes in the host; lowercase the authority first.
if (function_exists('mb_strtolower') && preg_match('#^([a-z][a-z0-9+.-]*://)([^/?\#]*)(.*)$#is', $url, $match)) {
$url = $match[1] . mb_strtolower($match[2], 'UTF-8') . $match[3];
}
$parts = parse_url($url);
if (!is_array($parts) || empty($parts['host'])) {
throw new RaijuNetException('invalid_url', 'Invalid URL.');
}
$scheme = strtolower((string)($parts['scheme'] ?? ''));
if (!in_array($scheme, ['http', 'https'], true)) {
throw new RaijuNetException('scheme', 'Only http and https URLs are allowed.');
}
if (isset($parts['user']) || isset($parts['pass'])) {
throw new RaijuNetException('credentials', 'URLs with credentials are not allowed.');
}
// Callers may add ports for their own purpose (extraPorts); the public web stays on 80 and 443.
$allowedPorts = array_values(array_unique(array_merge([80, 443], array_map('intval', (array)($options['extraPorts'] ?? [])))));
$testing = in_array(PHP_SAPI, ['cli', 'cli-server'], true);
if ($testing && is_array($options['testAllowedPorts'] ?? null)) {
$allowedPorts = array_map('intval', $options['testAllowedPorts']);
}
$port = (int)($parts['port'] ?? ($scheme === 'https' ? 443 : 80));
if (!in_array($port, $allowedPorts, true)) {
throw new RaijuNetException('port', 'Only ports 80 and 443 are allowed.');
}
$host = raijuNetNormalizeHost((string)$parts['host']);
if ($host === null) {
throw new RaijuNetException('invalid_host', 'Invalid host name.');
}
if (isset($options['allowedHosts']) && !in_array($host, array_map('strtolower', (array)$options['allowedHosts']), true)) {
throw new RaijuNetException('blocked_host', 'This host is not on the allowlist.');
}
if (is_callable($options['hostCheck'] ?? null)) {
// The caller's own policy (deny lists, the site's own host), applied to every hop before any lookup.
$reason = ($options['hostCheck'])($host);
if (is_string($reason) && $reason !== '') {
throw new RaijuNetException('blocked_host', $reason);
}
}
if (filter_var($host, FILTER_VALIDATE_IP)) {
$ips = [$host];
} else {
$labels = explode('.', $host);
$lastLabel = end($labels);
if (preg_match('/^(0x[0-9a-f]*|[0-9]+)$/i', (string)$lastLabel)) {
// Numeric but not a canonical dotted quad: 2130706433, 0x7f.1, 0177.0.0.1 ...
throw new RaijuNetException('invalid_host', 'Numeric host notation is not allowed.');
}
if ($host === 'localhost' || str_ends_with($host, '.localhost') || !str_contains($host, '.')) {
throw new RaijuNetException('blocked_host', 'Host is not publicly reachable.');
}
$resolver = $options['resolver'] ?? 'raijuNetResolveHost';
$ips = is_callable($resolver) ? $resolver($host) : [];
$ips = array_values(array_unique(array_filter(is_array($ips) ? $ips : [], 'is_string')));
if (!$ips) {
throw new RaijuNetException('unresolved', 'Host could not be resolved.');
}
}
foreach ($ips as $ip) {
if (!raijuNetIsPublicIp($ip)) {
throw new RaijuNetException('blocked_address', 'Host resolves to a non-public address.');
}
}
// IPv4 first: a host without IPv6 connectivity still connects.
usort($ips, static fn($a, $b) => (int)str_contains($a, ':') <=> (int)str_contains($b, ':'));
$path = ($parts['path'] ?? '') !== '' ? (string)$parts['path'] : '/';
$query = isset($parts['query']) && $parts['query'] !== '' ? '?' . $parts['query'] : '';
$authority = str_contains($host, ':') ? '[' . $host . ']' : $host;
$defaultPort = $scheme === 'https' ? 443 : 80;
if (isset($parts['port']) && $port !== $defaultPort) {
$authority .= ':' . $port;
}
return [
'url' => $scheme . '://' . $authority . $path . $query,
'scheme' => $scheme,
'host' => $host,
'port' => $port,
'path' => $path . $query,
'ips' => $ips,
];
}
/** Throws unless the URL passes every check of raijuNetSafeFetch(); returns the resolved addresses. */
function raijuNetAssertPublicUrl(string $url, array $options = []): array {
return raijuNetPrepareUrl($url, $options)['ips'];
}
/** Resolve a Location header against the URL it came from. */
function raijuNetResolveRedirect(string $baseUrl, string $location): string {
$location = trim($location);
if ($location === '' || preg_match('/[\x00-\x1F\x7F\\\\]/', $location)) {
throw new RaijuNetException('redirect', 'Invalid redirect target.');
}
if (preg_match('#^[a-z][a-z0-9+.-]*:#i', $location)) {
return $location;
}
$base = parse_url($baseUrl);
if (!is_array($base) || empty($base['scheme']) || empty($base['host'])) {
throw new RaijuNetException('redirect', 'Invalid redirect base.');
}
$origin = $base['scheme'] . '://' . $base['host'] . (isset($base['port']) ? ':' . $base['port'] : '');
if (str_starts_with($location, '//')) {
return $base['scheme'] . ':' . $location;
}
if (str_starts_with($location, '/')) {
$combined = $location;
} elseif (str_starts_with($location, '?')) {
$combined = ($base['path'] ?? '/') . $location;
} else {
$directory = preg_replace('#/[^/]*$#', '/', (string)($base['path'] ?? '/')) ?: '/';
$combined = $directory . $location;
}
$query = '';
if (($position = strpos($combined, '?')) !== false) {
$query = substr($combined, $position);
$combined = substr($combined, 0, $position);
}
$segments = [];
foreach (explode('/', $combined) as $segment) {
if ($segment === '..') {
array_pop($segments);
} elseif ($segment !== '.' && $segment !== '') {
$segments[] = $segment;
}
}
return $origin . '/' . implode('/', $segments) . (str_ends_with($combined, '/') && $segments ? '/' : '') . $query;
}
function raijuNetNormalizeContentType(string $contentType): string {
return strtolower(trim(explode(';', $contentType, 2)[0]));
}
/**
* Fetch a public URL with GET.
*
* Options: headers (extra request headers, first host only), maxBytes (2 MB), allowedContentTypes (html/xhtml/plain), allowedHosts (every hop,
* redirects included, must be one of them), maxRedirects (3, at
* most 3), connectTimeout (5 s), timeout (15 s), userAgent, accept, resolver (callable
* host => string[], for tests and alternative resolvers). Under the CLI SAPIs the tests
* may also pass testAllowedPorts, testConnectMap (validated IP => IP actually dialled)
* and testTransport (callable replacing curl).
*
* @return array{status:int,url:string,contentType:string,headers:array<string,string>,body:string,redirects:string[],ip:string}
* @throws RaijuNetException
*/
function raijuNetSafeFetch(string $url, array $options = []): array {
$testing = in_array(PHP_SAPI, ['cli', 'cli-server'], true);
$transport = $testing && is_callable($options['testTransport'] ?? null) ? $options['testTransport'] : null;
if ($transport === null && !function_exists('curl_init')) {
throw new RaijuNetException('transport', 'Outbound fetches require the curl extension.');
}
$maxBytes = max(1, (int)($options['maxBytes'] ?? RAIJU_NET_DEFAULT_MAX_BYTES));
$maxRedirects = max(0, min(RAIJU_NET_MAX_REDIRECTS, (int)($options['maxRedirects'] ?? RAIJU_NET_MAX_REDIRECTS)));
$allowedTypes = array_map('raijuNetNormalizeContentType', (array)($options['allowedContentTypes'] ?? RAIJU_NET_DEFAULT_CONTENT_TYPES));
$connectMap = $testing && is_array($options['testConnectMap'] ?? null) ? $options['testConnectMap'] : [];
$current = $url;
$redirects = [];
$firstHost = null;
$extraHeaders = [];
foreach ((array)($options['headers'] ?? []) as $header) {
if (is_string($header) && preg_match('/^[A-Za-z0-9-]{1,40}: [^\r\n]{0,400}$/', $header)) $extraHeaders[] = $header;
}
for ($hop = 0; $hop <= $maxRedirects; $hop++) {
$target = raijuNetPrepareUrl($current, $options);
$request = [
'url' => $target['url'],
'host' => $target['host'],
'port' => $target['port'],
'ips' => $target['ips'],
'dial' => array_map(static fn($ip) => $connectMap[$ip] ?? $ip, $target['ips']),
'maxBytes' => $maxBytes,
'allowedContentTypes' => $allowedTypes,
'connectTimeout' => (int)($options['connectTimeout'] ?? 5),
'timeout' => (int)($options['timeout'] ?? 15),
'userAgent' => (string)($options['userAgent'] ?? 'raiju-fetch/1.0'),
'accept' => (string)($options['accept'] ?? 'text/html,application/xhtml+xml,text/plain;q=0.8'),
// Extra request headers (an API key) go to the host the caller named, never to a redirect target.
'headers' => ($hop === 0 || ($target['host'] === ($firstHost ?? null))) ? $extraHeaders : [],
];
$firstHost ??= $target['host'];
$response = $transport !== null ? raijuNetRunTransport($transport, $request) : raijuNetCurlRequest($request);
$status = (int)$response['status'];
if ($status >= 300 && $status < 400 && ($response['headers']['location'] ?? '') !== '') {
if ($hop >= $maxRedirects) {
throw new RaijuNetException('redirect_limit', 'Too many redirects.');
}
$current = raijuNetResolveRedirect($target['url'], (string)$response['headers']['location']);
$redirects[] = $current;
continue;
}
if ($status < 200 || $status >= 300) {
throw new RaijuNetException('http_status', 'The server answered with HTTP ' . $status . '.', $status);
}
return [
'status' => $status,
'url' => $target['url'],
'contentType' => $response['contentType'],
'headers' => $response['headers'],
'body' => $response['body'],
'redirects' => $redirects,
'ip' => $target['ips'][0],
];
}
throw new RaijuNetException('redirect_limit', 'Too many redirects.');
}
/**
* Run an injected transport with the same streaming contract as curl: the callbacks
* abort the transfer by returning false, which keeps the limit logic under test.
*/
function raijuNetRunTransport(callable $transport, array $request): array {
$state = raijuNetStreamState($request);
$onHeader = static function (string $line) use (&$state): bool {
return raijuNetHandleHeaderLine($state, $line);
};
$onData = static function (string $chunk) use (&$state): bool {
return raijuNetHandleBodyChunk($state, $chunk);
};
$transport($request, $onHeader, $onData);
return raijuNetFinishResponse($state);
}
function raijuNetStreamState(array $request): array {
return [
'maxBytes' => $request['maxBytes'],
'allowedContentTypes' => $request['allowedContentTypes'],
'status' => 0, 'headers' => [], 'contentType' => '', 'body' => '', 'size' => 0, 'violation' => null,
];
}
/** Handle one raw header line; false aborts the transfer. */
function raijuNetHandleHeaderLine(array &$state, string $line): bool {
$line = rtrim($line, "\r\n");
if (preg_match('#^HTTP/\S+\s+(\d{3})#i', $line, $match)) {
// A new status line starts a new header block (100-continue, redirects).
$state['status'] = (int)$match[1];
$state['headers'] = [];
$state['contentType'] = '';
return true;
}
if ($line === '') {
$status = $state['status'];
$isRedirect = $status >= 300 && $status < 400 && ($state['headers']['location'] ?? '') !== '';
if ($status >= 200 && $status < 300 && !$isRedirect) {
$type = raijuNetNormalizeContentType((string)($state['headers']['content-type'] ?? ''));
$length = $state['headers']['content-length'] ?? '';
if (!in_array($type, $state['allowedContentTypes'], true)) {
$state['violation'] = ['content_type', 'Unsupported content type.'];
return false;
}
if (ctype_digit($length) && (int)$length > $state['maxBytes']) {
$state['violation'] = ['too_large', 'The response is too large.'];
return false;
}
}
return true;
}
if (str_contains($line, ':')) {
[$name, $value] = explode(':', $line, 2);
$state['headers'][strtolower(trim($name))] = trim($value);
if (strtolower(trim($name)) === 'content-type') {
$state['contentType'] = raijuNetNormalizeContentType($value);
}
}
return true;
}
/** Handle one body chunk; false aborts the transfer once the limit is exceeded. */
function raijuNetHandleBodyChunk(array &$state, string $chunk): bool {
$state['size'] += strlen($chunk);
$status = $state['status'];
$keep = $status >= 200 && $status < 300;
if ($state['size'] > $state['maxBytes']) {
if ($keep) {
$state['violation'] = ['too_large', 'The response is too large.'];
}
return false; // Error and redirect bodies are never kept, but never streamed endlessly either.
}
if ($keep) {
$state['body'] .= $chunk;
}
return true;
}
function raijuNetFinishResponse(array $state, string $transportError = ''): array {
if ($state['violation'] !== null) {
throw new RaijuNetException($state['violation'][0], $state['violation'][1]);
}
if ($state['status'] === 0) {
throw new RaijuNetException('transport', $transportError !== '' ? 'Request failed: ' . $transportError : 'Request failed.');
}
return [
'status' => $state['status'],
'headers' => $state['headers'],
'contentType' => $state['contentType'],
'body' => $state['body'],
];
}
function raijuNetCurlRequest(array $request): array {
$state = raijuNetStreamState($request);
$resolveAddresses = array_map(static fn($ip) => str_contains($ip, ':') ? '[' . $ip . ']' : $ip, $request['dial']);
$ch = curl_init($request['url']);
$options = [
CURLOPT_RETURNTRANSFER => false,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_MAXREDIRS => 0,
CURLOPT_CONNECTTIMEOUT => $request['connectTimeout'],
CURLOPT_TIMEOUT => $request['timeout'],
CURLOPT_USERAGENT => $request['userAgent'],
CURLOPT_HTTPHEADER => array_merge(['Accept: ' . $request['accept']], $request['headers']),
CURLOPT_ENCODING => 'gzip,deflate',
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_FRESH_CONNECT => true,
CURLOPT_FORBID_REUSE => true,
CURLOPT_PROXY => '',
CURLOPT_RESOLVE => [$request['host'] . ':' . $request['port'] . ':' . implode(',', $resolveAddresses)],
CURLOPT_HEADERFUNCTION => static function ($handle, string $line) use (&$state): int {
return raijuNetHandleHeaderLine($state, $line) ? strlen($line) : 0;
},
CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (&$state): int {
return raijuNetHandleBodyChunk($state, $chunk) ? strlen($chunk) : 0;
},
];
if (defined('CURLOPT_PROTOCOLS_STR')) {
$options[CURLOPT_PROTOCOLS_STR] = 'http,https';
$options[CURLOPT_REDIR_PROTOCOLS_STR] = 'http,https';
} elseif (defined('CURLOPT_PROTOCOLS')) {
$options[CURLOPT_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
$options[CURLOPT_REDIR_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
}
curl_setopt_array($ch, $options);
curl_exec($ch);
$error = curl_error($ch);
unset($ch);
return raijuNetFinishResponse($state, $error);
}