From 5da0d865e04796409fcacf80cadc054309be1bc7 Mon Sep 17 00:00:00 2001 From: upstream Date: Fri, 2 Oct 2026 16:23:14 +0200 Subject: [PATCH] Upstream kanboard/kanboard state of the touched files before 0131fcfe61 --- app/Core/Http/Client.php | 451 +++++++++++++++++++++++++++ tests/units/Core/Http/ClientTest.php | 82 +++++ 2 files changed, 533 insertions(+) create mode 100644 app/Core/Http/Client.php create mode 100644 tests/units/Core/Http/ClientTest.php diff --git a/app/Core/Http/Client.php b/app/Core/Http/Client.php new file mode 100644 index 0000000..d860f50 --- /dev/null +++ b/app/Core/Http/Client.php @@ -0,0 +1,451 @@ +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; + } + + 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 = trim($parsedUrl['host']); + if ($host === '') { + return false; + } + + $ipv4Address = gethostbyname($host); + if ($this->isPrivateIpAddress($ipv4Address)) { + return true; + } + + 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'])) { + if ($this->isPrivateIpAddress($record['ipv6'])) { + return true; + } + } + } + } + } + + return false; + } +} diff --git a/tests/units/Core/Http/ClientTest.php b/tests/units/Core/Http/ClientTest.php new file mode 100644 index 0000000..01c6215 --- /dev/null +++ b/tests/units/Core/Http/ClientTest.php @@ -0,0 +1,82 @@ +getMockBuilder('\Kanboard\Core\Http\Client') + ->setConstructorArgs(array($this->container)) + ->onlyMethods(array('doRequest')) + ->getMock(); + + $client + ->expects($this->once()) + ->method('doRequest') + ->with('GET', 'https://example.org', '', array('X-Test: 1'), true, false) + ->willReturn('response-body'); + + $this->assertSame( + 'response-body', + $client->get('https://example.org', array('X-Test: 1'), true, false) + ); + } + + public function testIsPrivateIpAddressWithPrivateIPv4() + { + $client = new Client($this->container); + + $this->assertTrue($client->isPrivateIpAddress('10.0.0.1')); + $this->assertTrue($client->isPrivateIpAddress('172.16.5.10')); + $this->assertTrue($client->isPrivateIpAddress('192.168.1.20')); + $this->assertTrue($client->isPrivateIpAddress('127.0.0.1')); + } + + public function testIsPrivateIpAddressWithPrivateIPv6() + { + $client = new Client($this->container); + + $this->assertTrue($client->isPrivateIpAddress('::1')); + $this->assertTrue($client->isPrivateIpAddress('fd12:3456:789a:1::1')); + } + + public function testIsPrivateIpAddressWithPublicAndInvalidValues() + { + $client = new Client($this->container); + + $this->assertFalse($client->isPrivateIpAddress('8.8.8.8')); + $this->assertFalse($client->isPrivateIpAddress('2607:f8b0:4005:805::200e')); + $this->assertFalse($client->isPrivateIpAddress('not-an-ip')); + $this->assertFalse($client->isPrivateIpAddress('')); + } + + public function testIsPrivateUrlWithPrivateHostnames() + { + $client = new Client($this->container); + + $this->assertTrue($client->isPrivateURL('http://localhost')); + $this->assertTrue($client->isPrivateURL('http://127.0.0.1/path')); + $this->assertTrue($client->isPrivateURL('http://10.0.0.15/api')); + } + + public function testIsPrivateUrlWithPublicAddresses() + { + $client = new Client($this->container); + + $this->assertFalse($client->isPrivateURL('http://8.8.8.8/data')); + $this->assertFalse($client->isPrivateURL('https://ipv6.google.com/test')); + } + + public function testIsPrivateUrlWithUnsupportedScheme() + { + $client = new Client($this->container); + + $this->assertFalse($client->isPrivateURL('ftp://127.0.0.1')); + $this->assertFalse($client->isPrivateURL('mailto:test@example.com')); + } +}