573 lines
24 KiB
PHP
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);
|
|
}
|