Files
kanboard-kanboard/app/Core/Http/Client.php
T
dualfroz cce89ba4a2 fix(http): normalize the host before the private network check
parse_url() keeps the brackets around an IPv6 literal, so "[::1]" or
"[::ffff:127.0.0.1]" never matched an address and isPrivateURL() let the
request through. A host that does not resolve was allowed for the same
reason, while cURL still connects to forms like "0x7f000001" or
"127.0.0.1." on its own.

Strip the brackets and the trailing dot, check IP literals directly, check
every resolved address, and treat a host without any address as private.
IPv4-mapped IPv6 addresses are checked through their IPv4 part, since
FILTER_FLAG_NO_RES_RANGE does not classify them the same way on every PHP
version.

Fixes #5858
Fixes #5871
2026-10-03 17:43:14 +02:00

469 lines
15 KiB
PHP

<?php
namespace Kanboard\Core\Http;
use Kanboard\Core\Base;
use Kanboard\Job\HttpAsyncJob;
/**
* HTTP client
*
* @package Kanboard\Core\Http
* @author Frederic Guillot
*/
class Client extends Base
{
/**
* HTTP client user agent
*
* @var string
*/
const HTTP_USER_AGENT = 'Kanboard';
/**
* Send a GET HTTP request
*
* @access public
* @param string $url
* @param string[] $headers
* @param bool $raiseForErrors
* @param bool $followRedirects
* @return string
*/
public function get($url, array $headers = [], $raiseForErrors = false, $followRedirects = true)
{
return $this->doRequest('GET', $url, '', $headers, $raiseForErrors, $followRedirects);
}
/**
* Send a GET HTTP request and parse JSON response
*
* @access public
* @param string $url
* @param string[] $headers
* @param bool $raiseForErrors
* @param bool $followRedirects
* @return array
*/
public function getJson($url, array $headers = [], $raiseForErrors = false, $followRedirects = true)
{
$response = $this->doRequest('GET', $url, '', array_merge(['Accept: application/json'], $headers), $raiseForErrors, $followRedirects);
return json_decode($response, true) ?: [];
}
/**
* Send a POST HTTP request encoded in JSON
*
* @access public
* @param string $url
* @param array $data
* @param string[] $headers
* @param bool $raiseForErrors
* @param bool $followRedirects
* @return string
*/
public function postJson($url, array $data, array $headers = [], $raiseForErrors = false, $followRedirects = true)
{
return $this->doRequest(
'POST',
$url,
json_encode($data),
array_merge(['Content-type: application/json'], $headers),
$raiseForErrors,
$followRedirects
);
}
/**
* Send a POST HTTP request encoded in JSON (Fire and forget)
*
* @access public
* @param string $url
* @param array $data
* @param string[] $headers
* @param bool $raiseForErrors
*/
public function postJsonAsync($url, array $data, array $headers = [], $raiseForErrors = false)
{
$this->queueManager->push(HttpAsyncJob::getInstance($this->container)->withParams(
'POST',
$url,
json_encode($data),
array_merge(['Content-type: application/json'], $headers),
$raiseForErrors
));
}
/**
* Send a POST HTTP request encoded in www-form-urlencoded
*
* @access public
* @param string $url
* @param array $data
* @param string[] $headers
* @param bool $raiseForErrors
* @return string
*/
public function postForm($url, array $data, array $headers = [], $raiseForErrors = false)
{
return $this->doRequest(
'POST',
$url,
http_build_query($data),
array_merge(['Content-type: application/x-www-form-urlencoded'], $headers),
$raiseForErrors
);
}
/**
* Send a POST HTTP request encoded in www-form-urlencoded (fire and forget)
*
* @access public
* @param string $url
* @param array $data
* @param string[] $headers
* @param bool $raiseForErrors
*/
public function postFormAsync($url, array $data, array $headers = [], $raiseForErrors = false)
{
$this->queueManager->push(HttpAsyncJob::getInstance($this->container)->withParams(
'POST',
$url,
http_build_query($data),
array_merge(['Content-type: application/x-www-form-urlencoded'], $headers),
$raiseForErrors
));
}
/**
* Make the HTTP request with cURL if detected, socket otherwise
*
* @access public
* @param string $method
* @param string $url
* @param string $content
* @param string[] $headers
* @param bool $raiseForErrors
* @param bool $followRedirects
* @return string
*/
public function doRequest($method, $url, $content, array $headers, $raiseForErrors = false, $followRedirects = true)
{
$requestBody = '';
if (! empty($url)) {
if (function_exists('curl_version')) {
if (DEBUG) {
$this->logger->debug('HttpClient::doRequest: cURL detected');
}
$requestBody = $this->doRequestWithCurl($method, $url, $content, $headers, $raiseForErrors, $followRedirects);
} else {
if (DEBUG) {
$this->logger->debug('HttpClient::doRequest: using socket');
}
$requestBody = $this->doRequestWithSocket($method, $url, $content, $headers, $raiseForErrors, $followRedirects);
}
}
return $requestBody;
}
/**
* Make the HTTP request with socket
*
* @access private
* @param string $method
* @param string $url
* @param string $content
* @param string[] $headers
* @param bool $raiseForErrors
* @param bool $followRedirects
* @return string
*/
private function doRequestWithSocket($method, $url, $content, array $headers, $raiseForErrors = false, $followRedirects = true)
{
$startTime = microtime(true);
$stream = @fopen(trim($url), 'r', false, stream_context_create($this->getContext($method, $content, $headers, $raiseForErrors, $followRedirects)));
if (! is_resource($stream)) {
$this->logger->error('HttpClient: request failed ('.$url.')');
if ($raiseForErrors) {
throw new ClientException('Unreachable URL: '.$url);
}
return '';
}
$body = stream_get_contents($stream);
$metadata = stream_get_meta_data($stream);
if ($raiseForErrors && array_key_exists('wrapper_data', $metadata)) {
$statusCode = $this->getStatusCode($metadata['wrapper_data']);
if ($statusCode >= 400) {
throw new InvalidStatusException('Request failed with status code '.$statusCode, $statusCode, $body);
}
}
if (DEBUG) {
$this->logger->debug('HttpClient: url='.$url);
$this->logger->debug('HttpClient: headers='.var_export($headers, true));
$this->logger->debug('HttpClient: payload='.$content);
$this->logger->debug('HttpClient: metadata='.var_export($metadata, true));
$this->logger->debug('HttpClient: body='.$body);
$this->logger->debug('HttpClient: executionTime='.(microtime(true) - $startTime));
}
return $body;
}
/**
* Make the HTTP request with cURL
*
* @access private
* @param string $method
* @param string $url
* @param string $content
* @param string[] $headers
* @param bool $raiseForErrors
* @param bool $followRedirects
* @return string
*/
private function doRequestWithCurl($method, $url, $content, array $headers, $raiseForErrors = false, $followRedirects = true)
{
$startTime = microtime(true);
$curlSession = @curl_init();
curl_setopt($curlSession, CURLOPT_URL, trim($url));
curl_setopt($curlSession, CURLOPT_USERAGENT, self::HTTP_USER_AGENT);
curl_setopt($curlSession, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
curl_setopt($curlSession, CURLOPT_TIMEOUT, HTTP_TIMEOUT);
curl_setopt($curlSession, CURLOPT_FORBID_REUSE, true);
curl_setopt($curlSession, CURLOPT_MAXREDIRS, $followRedirects ? HTTP_MAX_REDIRECTS : 0);
curl_setopt($curlSession, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curlSession, CURLOPT_FOLLOWLOCATION, $followRedirects);
if ('POST' === $method) {
curl_setopt($curlSession, CURLOPT_POST, true);
curl_setopt($curlSession, CURLOPT_POSTFIELDS, $content);
} elseif ('PUT' === $method) {
curl_setopt($curlSession, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($curlSession, CURLOPT_POST, true);
curl_setopt($curlSession, CURLOPT_POSTFIELDS, $content);
}
if (! empty($headers)) {
curl_setopt($curlSession, CURLOPT_HTTPHEADER, $headers);
}
if (HTTP_VERIFY_SSL_CERTIFICATE === false) {
curl_setopt($curlSession, CURLOPT_SSL_VERIFYHOST, 0);
curl_setopt($curlSession, CURLOPT_SSL_VERIFYPEER, false);
}
if (HTTP_PROXY_HOSTNAME) {
curl_setopt($curlSession, CURLOPT_PROXY, HTTP_PROXY_HOSTNAME);
curl_setopt($curlSession, CURLOPT_PROXYPORT, HTTP_PROXY_PORT);
curl_setopt($curlSession, CURLOPT_NOPROXY, HTTP_PROXY_EXCLUDE);
}
if (HTTP_PROXY_USERNAME) {
curl_setopt($curlSession, CURLOPT_PROXYAUTH, CURLAUTH_BASIC);
curl_setopt($curlSession, CURLOPT_PROXYUSERPWD, HTTP_PROXY_USERNAME.':'.HTTP_PROXY_PASSWORD);
}
$body = curl_exec($curlSession);
if ($body === false) {
$errorMsg = curl_error($curlSession);
curl_close($curlSession);
$this->logger->error('HttpClient: request failed ('.$url.' - '.$errorMsg.')');
if ($raiseForErrors) {
throw new ClientException('Unreachable URL: '.$url.' ('.$errorMsg.')');
}
return '';
}
if ($raiseForErrors) {
$statusCode = curl_getinfo($curlSession, CURLINFO_RESPONSE_CODE);
if ($statusCode >= 400) {
curl_close($curlSession);
throw new InvalidStatusException('Request failed with status code '.$statusCode, $statusCode, $body);
}
}
if (DEBUG) {
$this->logger->debug('HttpClient: url='.$url);
$this->logger->debug('HttpClient: headers='.var_export($headers, true));
$this->logger->debug('HttpClient: payload='.$content);
$this->logger->debug('HttpClient: metadata='.var_export(curl_getinfo($curlSession), true));
$this->logger->debug('HttpClient: body='.$body);
$this->logger->debug('HttpClient: executionTime='.(microtime(true) - $startTime));
}
curl_close($curlSession);
return $body;
}
/**
* Get stream context
*
* @access private
* @param string $method
* @param string $content
* @param string[] $headers
* @param bool $raiseForErrors
* @param bool $followRedirects
* @return array
*/
private function getContext($method, $content, array $headers, $raiseForErrors = false, $followRedirects = true)
{
$default_headers = [
'User-Agent: '.self::HTTP_USER_AGENT,
'Connection: close',
];
if (HTTP_PROXY_USERNAME) {
$default_headers[] = 'Proxy-Authorization: Basic '.base64_encode(HTTP_PROXY_USERNAME.':'.HTTP_PROXY_PASSWORD);
}
$headers = array_merge($default_headers, $headers);
$context = [
'http' => [
'method' => $method,
'protocol_version' => 1.1,
'timeout' => HTTP_TIMEOUT,
'max_redirects' => $followRedirects ? HTTP_MAX_REDIRECTS : 0,
'follow_location' => $followRedirects ? 1 : 0,
'header' => implode("\r\n", $headers),
'content' => $content,
'ignore_errors' => $raiseForErrors,
]
];
if (HTTP_PROXY_HOSTNAME) {
$context['http']['proxy'] = 'tcp://'.HTTP_PROXY_HOSTNAME.':'.HTTP_PROXY_PORT;
$context['http']['request_fulluri'] = true;
}
if (HTTP_VERIFY_SSL_CERTIFICATE === false) {
$context['ssl'] = [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
];
}
return $context;
}
private function getStatusCode(array $lines)
{
$status = 200;
foreach ($lines as $line) {
if (strpos($line, 'HTTP/1') === 0) {
$status = (int) substr($line, 9, 3);
}
}
return $status;
}
/**
* Get backend used for making HTTP connections
*
* @access public
* @return string
*/
public static function backend()
{
return function_exists('curl_version') ? 'cURL' : 'socket';
}
/**
* Check if an IP address is private
*
* @access public
* @param string $ip
* @return bool
*/
public function isPrivateIpAddress($ip)
{
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
return false;
}
// IPv4-mapped IPv6 address (::ffff:a.b.c.d), check the IPv4 part
$address = inet_pton($ip);
if (strlen($address) === 16 && substr($address, 0, 12) === str_repeat("\0", 10)."\xff\xff") {
$ip = inet_ntop(substr($address, 12));
}
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false;
}
/**
* Check if a URL is private (RFC1918, localhost, etc.)
*
* @access public
* @param string $url
* @return bool
*/
public function isPrivateURL($url)
{
$parsedUrl = parse_url($url);
if (!isset($parsedUrl['scheme']) || !in_array(strtolower($parsedUrl['scheme']), ['http', 'https'], true)) {
return false;
}
if (!isset($parsedUrl['host'])) {
return false;
}
$host = rtrim(trim($parsedUrl['host'], " \t\n\r\0\x0B[]"), '.');
if ($host === '') {
return false;
}
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
return $this->isPrivateIpAddress($host);
}
$addresses = gethostbynamel($host) ?: [];
if (function_exists('dns_get_record')) {
$dnsRecords = @dns_get_record($host, DNS_AAAA);
if (is_array($dnsRecords)) {
foreach ($dnsRecords as $record) {
if (isset($record['type']) && $record['type'] === 'AAAA' && isset($record['ipv6'])) {
$addresses[] = $record['ipv6'];
}
}
}
}
// Refuse hosts we cannot resolve: the HTTP client may still connect to them,
// for example cURL reads "0x7f000001" as 127.0.0.1.
if (empty($addresses)) {
return true;
}
foreach ($addresses as $address) {
if ($this->isPrivateIpAddress($address)) {
return true;
}
}
return false;
}
}