2048 || preg_match('/[^\x21-\x7e]/', $url) || str_contains($url,'\\')) { throw new \RuntimeException('Use a canonical HTTPS address.'); } $p = wp_parse_url($url); if (!is_array($p) || ($p['scheme'] ?? '') !== 'https' || isset($p['user'],$p['pass']) || isset($p['user']) || isset($p['pass']) || isset($p['fragment']) || (isset($p['port']) && $p['port'] !== 443)) { throw new \RuntimeException('Only HTTPS on port 443 without credentials or fragments is supported.'); } $host = strtolower($p['host'] ?? ''); if (strlen($host) > 253 || !preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/D',$host)) { throw new \RuntimeException('A public DNS hostname is required.'); } return $p + ['path'=>'/']; } public static function addresses(string $host, int $depth = 0, ?int &$remaining = null): array { if ($remaining === null) { $remaining = 8; } if ($depth > 4 || --$remaining < 0) { throw new \RuntimeException('The public DNS alias chain is unsupported.'); } $records = @dns_get_record($host, DNS_A | DNS_AAAA | DNS_CNAME); if (!is_array($records) || count($records) > 32) { throw new \RuntimeException('Public DNS resolution is unavailable.'); } $ips = []; foreach ($records as $row) { $ip = $row['ip'] ?? $row['ipv6'] ?? null; if ($ip !== null) { if (!is_string($ip) || !self::publicIp($ip)) { throw new \RuntimeException('Private, reserved or mixed-address destinations are not supported.'); } $ips[] = $ip; } elseif (($row['type'] ?? '') === 'CNAME') { $target = strtolower(rtrim((string)($row['target'] ?? ''),'.')); self::url('https://' . $target . '/'); $ips = array_merge($ips,self::addresses($target,$depth+1,$remaining)); } } $ips = array_values(array_unique($ips)); if (!$ips || count($ips) > 32) { throw new \RuntimeException('No supported public destination was resolved.'); } return $ips; } /** * WordPress owns dispatch, outbound policy, transport and response parsing. * These request-scoped hooks add guarantees needed by security probes. */ public static function get(string $url, int $limit = 4096, array $headers = []): array { $p = self::url($url); if ($limit < 1 || $limit > 1048576 || !function_exists('curl_setopt_array') || !class_exists('WpOrg\\Requests\\Transport\\Curl') || !class_exists('WP_HTTP_Requests_Hooks')) { throw new \RuntimeException('This bounded HTTPS check requires the supported WordPress cURL transport and a valid response limit.'); } if ((new \WP_Http())->block_request($url)) { throw new \RuntimeException('The host outbound-request policy does not permit this destination.'); } $requestHeaders = ['Accept-Encoding'=>'identity','Cache-Control'=>'no-cache']; foreach ($headers as $name => $value) { if (!in_array(strtolower((string)$name), ['range','add-padding','accept','authorization'], true) || !is_string($value) || strlen($value) > 2048 || preg_match('/[\r\n\x00]/',$value)) { throw new \RuntimeException('Unsupported request header.'); } $requestHeaders[$name] = $value; } $host = strtolower($p['host']); $ips = self::addresses($host); $ip = $ips[0]; $marker = bin2hex(random_bytes(16)); $markerHeader = 'X-Shieldlume-Transport'; $body = ''; $responseHeaders = []; $headerBytes = 0; $limited = false; $headerLimit = false; $prepared = false; $pinned = false; $completed = false; $status = 0; $handle = null; $transport = null; $failure = 'The pinned HTTPS check did not complete. No result is inferred.'; $abort = static function (): void { throw new \WpOrg\Requests\Exception('The pinned HTTPS check did not complete. No result is inferred.','shieldlume_pinned_http'); }; // The internal marker identifies only this request, including nested requests // to the same URL. It is removed before a transport can send its headers. $prepare = static function (&$requestUrl,&$requestHeadersIn,&$data,&$type,&$options) use ($url,$marker,$markerHeader,$requestHeaders,$limit,&$prepared,&$transport,&$handle,&$completed,&$status,&$pinned,&$limited,&$headerLimit,$ip,$abort): void { if (!is_array($requestHeadersIn) || ($requestHeadersIn[$markerHeader] ?? null) !== $marker) { return; } unset($requestHeadersIn[$markerHeader]); if ($prepared || $requestUrl !== $url || !($options['hooks'] instanceof \WP_HTTP_Requests_Hooks)) { $abort(); } $prepared = true; $transport = new \WpOrg\Requests\Transport\Curl(); $options['transport'] = $transport; $requestHeadersIn = $requestHeaders; $data = []; $type = 'GET'; // Requests applies TLS options after http_api_curl; enforce them here // too, so a general https_ssl_verify filter cannot weaken this read. $options['verify'] = true; $options['verifyname'] = true; $options['type'] = 'GET'; $options['blocking'] = true; $options['filename'] = false; $options['proxy'] = false; $options['follow_redirects'] = false; $options['redirects'] = 0; $options['max_bytes'] = $limit; $options['timeout'] = 6; $options['connect_timeout'] = 2.5; $options['hooks']->register('curl.after_send',static function () use (&$handle,&$completed,&$status,&$pinned,&$limited,&$headerLimit,$ip,$url,$abort): void { if (!$pinned || $handle === null || $completed) { $abort(); } // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_getinfo,WordPress.WP.AlternativeFunctions.curl_curl_errno -- Inspect WordPress's transport to prove the selected peer and bounded read; this does not dispatch a request. $info = curl_getinfo($handle); $error = curl_errno($handle); if ($headerLimit || !is_array($info) || @inet_pton((string)($info['primary_ip'] ?? '')) !== @inet_pton($ip) || (int)($info['primary_port'] ?? 0) !== 443 || (int)($info['ssl_verify_result'] ?? -1) !== 0 || (int)($info['redirect_count'] ?? -1) !== 0 || ($info['url'] ?? '') !== $url || ($error !== 0 && !($limited && $error === CURLE_WRITE_ERROR))) { $abort(); } $status = (int)($info['http_code'] ?? 0); if ($status < 100 || $status > 599) { $abort(); } $completed = true; // Requests retries WRITE_ERROR/BAD_CONTENT_ENCODING. Stop before // that retry; only our proven intentional truncation is usable. if ($error !== 0) { $abort(); } },PHP_INT_MAX); }; $pin = static function ($curl,$args,$requestUrl) use ($url,$host,$ip,$marker,$requestHeaders,$limit,&$prepared,&$transport,&$handle,&$pinned,&$body,&$responseHeaders,&$headerBytes,&$limited,&$headerLimit,$abort): void { if (($args['_shieldlume_transport'] ?? null) !== $marker) { return; } if (!$prepared || $pinned || $requestUrl !== $url || !($transport instanceof \WpOrg\Requests\Transport\Curl)) { $abort(); } $handle = $curl; $wireHeaders = ['Connection: close']; foreach ($requestHeaders as $name=>$value) { $wireHeaders[] = $name . ': ' . $value; } // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt_array -- Configure WordPress's handle through http_api_curl; WordPress alone dispatches and closes the request. $ok = curl_setopt_array($curl,[ CURLOPT_FOLLOWLOCATION=>false,CURLOPT_MAXREDIRS=>0,CURLOPT_PROTOCOLS=>CURLPROTO_HTTPS, CURLOPT_PROXY=>'',CURLOPT_PRE_PROXY=>'',CURLOPT_NOPROXY=>'*',CURLOPT_CONNECT_TO=>[],CURLOPT_UNIX_SOCKET_PATH=>null, CURLOPT_CONNECTTIMEOUT_MS=>2500,CURLOPT_TIMEOUT_MS=>6000,CURLOPT_SSL_VERIFYPEER=>true,CURLOPT_SSL_VERIFYHOST=>2, CURLOPT_HTTPGET=>true,CURLOPT_NOBODY=>false,CURLOPT_CUSTOMREQUEST=>null,CURLOPT_HTTPAUTH=>CURLAUTH_NONE, CURLOPT_FRESH_CONNECT=>true,CURLOPT_FORBID_REUSE=>true,CURLOPT_NOSIGNAL=>true, CURLOPT_HTTPHEADER=>$wireHeaders,CURLOPT_ENCODING=>'identity',CURLOPT_HTTP_CONTENT_DECODING=>false, CURLOPT_RESOLVE=>[$host . ':443:' . (str_contains($ip,':') ? '['.$ip.']' : $ip)], CURLOPT_HEADERFUNCTION=>static function ($ch,string $line) use (&$responseHeaders,&$headerBytes,&$headerLimit,$transport): int { $headerBytes += strlen($line); if ($headerBytes > 16384) { $headerLimit = true; return 0; } if (str_starts_with($line,'HTTP/')) { $responseHeaders = []; } if (str_contains($line,':')) { [$name,$value] = explode(':',$line,2); $name = strtolower(trim($name)); if (in_array($name,['content-type','content-range','content-length','content-encoding','etag','last-modified'],true)) { $responseHeaders[$name] = trim($value); } // Requests decompresses encoded bodies even with WordPress's // decompress=false. Reject encoding before parsing can inflate // the bounded response; an identity label needs no decoding. if ($name === 'content-encoding') { return strtolower(trim($value)) === 'identity' ? strlen($line) : 0; } } return $transport->stream_headers($ch,$line); }, CURLOPT_WRITEFUNCTION=>static function ($ch,string $chunk) use (&$body,&$limited,$limit,$transport): int { $remaining = $limit - strlen($body); $body .= substr($chunk,0,max(0,$remaining)); if (strlen($chunk) > $remaining) { $limited = true; return 0; } return $transport->stream_body($ch,$chunk); }, ]); if (!$ok) { $abort(); } $pinned = true; }; $guardArgs = static function ($args,$requestUrl) use ($url,$marker,$markerHeader,$requestHeaders,$limit,$abort): array { if (($args['_shieldlume_transport'] ?? null) !== $marker) { return $args; } if ($requestUrl !== $url) { $abort(); } return array_replace($args,[ 'method'=>'GET','body'=>null,'cookies'=>[],'timeout'=>6,'redirection'=>0, 'sslverify'=>true,'blocking'=>true,'stream'=>false,'filename'=>null, 'compress'=>false,'decompress'=>false,'reject_unsafe_urls'=>true, 'limit_response_size'=>$limit,'headers'=>$requestHeaders + [$markerHeader=>$marker], ]); }; add_filter('http_request_args',$guardArgs,PHP_INT_MAX,2); add_action('requests-requests.before_request',$prepare,PHP_INT_MAX,5); add_action('http_api_curl',$pin,PHP_INT_MAX,3); try { $response = wp_safe_remote_get($url,[ 'timeout'=>6,'redirection'=>0,'sslverify'=>true,'blocking'=>true,'stream'=>false, 'decompress'=>false,'limit_response_size'=>$limit,'cookies'=>[], 'user-agent'=>'Shieldlume Security bounded check', 'headers'=>$requestHeaders + [$markerHeader=>$marker], '_shieldlume_transport'=>$marker, ]); // A pre_http_request cache/mock response never establishes this proof. if (!$prepared || !$pinned || !$completed || $headerLimit) { throw new \RuntimeException($failure); } if (!$limited && (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== $status || wp_remote_retrieve_body($response) !== $body)) { throw new \RuntimeException($failure); } return ['status'=>$status,'body'=>$body,'headers'=>$responseHeaders,'limited'=>$limited]; } finally { remove_filter('http_request_args',$guardArgs,PHP_INT_MAX); remove_action('requests-requests.before_request',$prepare,PHP_INT_MAX); remove_action('http_api_curl',$pin,PHP_INT_MAX); // Release callbacks capturing the Requests transport after exceptions. if ($handle !== null) { // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt_array -- Remove this request's callbacks; WordPress owns the transport and its lifetime. curl_setopt_array($handle,[CURLOPT_HEADERFUNCTION=>null,CURLOPT_WRITEFUNCTION=>null]); } } } }