diff options
Diffstat (limited to 'lib/private/Http')
-rw-r--r-- | lib/private/Http/Client/Client.php | 685 | ||||
-rw-r--r-- | lib/private/Http/Client/ClientService.php | 79 | ||||
-rw-r--r-- | lib/private/Http/Client/DnsPinMiddleware.php | 145 | ||||
-rw-r--r-- | lib/private/Http/Client/GuzzlePromiseAdapter.php | 124 | ||||
-rw-r--r-- | lib/private/Http/Client/NegativeDnsCache.php | 33 | ||||
-rw-r--r-- | lib/private/Http/Client/Response.php | 46 | ||||
-rw-r--r-- | lib/private/Http/CookieHelper.php | 58 | ||||
-rw-r--r-- | lib/private/Http/WellKnown/RequestManager.php | 109 |
8 files changed, 1279 insertions, 0 deletions
diff --git a/lib/private/Http/Client/Client.php b/lib/private/Http/Client/Client.php new file mode 100644 index 00000000000..553a8921a80 --- /dev/null +++ b/lib/private/Http/Client/Client.php @@ -0,0 +1,685 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Http\Client; + +use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\Promise\PromiseInterface; +use GuzzleHttp\RequestOptions; +use OCP\Http\Client\IClient; +use OCP\Http\Client\IPromise; +use OCP\Http\Client\IResponse; +use OCP\Http\Client\LocalServerException; +use OCP\ICertificateManager; +use OCP\IConfig; +use OCP\Security\IRemoteHostValidator; +use Psr\Log\LoggerInterface; +use function parse_url; + +/** + * Class Client + * + * @package OC\Http + */ +class Client implements IClient { + /** @var GuzzleClient */ + private $client; + /** @var IConfig */ + private $config; + /** @var ICertificateManager */ + private $certificateManager; + private IRemoteHostValidator $remoteHostValidator; + + public function __construct( + IConfig $config, + ICertificateManager $certificateManager, + GuzzleClient $client, + IRemoteHostValidator $remoteHostValidator, + protected LoggerInterface $logger, + ) { + $this->config = $config; + $this->client = $client; + $this->certificateManager = $certificateManager; + $this->remoteHostValidator = $remoteHostValidator; + } + + private function buildRequestOptions(array $options): array { + $proxy = $this->getProxyUri(); + + $defaults = [ + RequestOptions::VERIFY => $this->getCertBundle(), + RequestOptions::TIMEOUT => IClient::DEFAULT_REQUEST_TIMEOUT, + ]; + + $options['nextcloud']['allow_local_address'] = $this->isLocalAddressAllowed($options); + if ($options['nextcloud']['allow_local_address'] === false) { + $onRedirectFunction = function ( + \Psr\Http\Message\RequestInterface $request, + \Psr\Http\Message\ResponseInterface $response, + \Psr\Http\Message\UriInterface $uri, + ) use ($options) { + $this->preventLocalAddress($uri->__toString(), $options); + }; + + $defaults[RequestOptions::ALLOW_REDIRECTS] = [ + 'on_redirect' => $onRedirectFunction + ]; + } + + // Only add RequestOptions::PROXY if Nextcloud is explicitly + // configured to use a proxy. This is needed in order not to override + // Guzzle default values. + if ($proxy !== null) { + $defaults[RequestOptions::PROXY] = $proxy; + } + + $options = array_merge($defaults, $options); + + if (!isset($options[RequestOptions::HEADERS]['User-Agent'])) { + $options[RequestOptions::HEADERS]['User-Agent'] = 'Nextcloud Server Crawler'; + } + + if (!isset($options[RequestOptions::HEADERS]['Accept-Encoding'])) { + $options[RequestOptions::HEADERS]['Accept-Encoding'] = 'gzip'; + } + + // Fallback for save_to + if (isset($options['save_to'])) { + $options['sink'] = $options['save_to']; + unset($options['save_to']); + } + + return $options; + } + + private function getCertBundle(): string { + // If the instance is not yet setup we need to use the static path as + // $this->certificateManager->getAbsoluteBundlePath() tries to instantiate + // a view + if (!$this->config->getSystemValueBool('installed', false)) { + return \OC::$SERVERROOT . '/resources/config/ca-bundle.crt'; + } + + return $this->certificateManager->getAbsoluteBundlePath(); + } + + /** + * Returns a null or an associative array specifying the proxy URI for + * 'http' and 'https' schemes, in addition to a 'no' key value pair + * providing a list of host names that should not be proxied to. + * + * @return array|null + * + * The return array looks like: + * [ + * 'http' => 'username:password@proxy.example.com', + * 'https' => 'username:password@proxy.example.com', + * 'no' => ['foo.com', 'bar.com'] + * ] + * + */ + private function getProxyUri(): ?array { + $proxyHost = $this->config->getSystemValueString('proxy', ''); + + if ($proxyHost === '') { + return null; + } + + $proxyUserPwd = $this->config->getSystemValueString('proxyuserpwd', ''); + if ($proxyUserPwd !== '') { + $proxyHost = $proxyUserPwd . '@' . $proxyHost; + } + + $proxy = [ + 'http' => $proxyHost, + 'https' => $proxyHost, + ]; + + $proxyExclude = $this->config->getSystemValue('proxyexclude', []); + if ($proxyExclude !== [] && $proxyExclude !== null) { + $proxy['no'] = $proxyExclude; + } + + return $proxy; + } + + private function isLocalAddressAllowed(array $options) : bool { + if (($options['nextcloud']['allow_local_address'] ?? false) + || $this->config->getSystemValueBool('allow_local_remote_servers', false)) { + return true; + } + + return false; + } + + protected function preventLocalAddress(string $uri, array $options): void { + $host = parse_url($uri, PHP_URL_HOST); + if ($host === false || $host === null) { + throw new LocalServerException('Could not detect any host'); + } + + if ($this->isLocalAddressAllowed($options)) { + return; + } + + if (!$this->remoteHostValidator->isValid($host)) { + throw new LocalServerException('Host "' . $host . '" violates local access rules'); + } + } + + /** + * Sends a GET request + * + * @param string $uri + * @param array $options Array such as + * 'query' => [ + * 'field' => 'abc', + * 'other_field' => '123', + * 'file_name' => fopen('/path/to/file', 'r'), + * ], + * 'headers' => [ + * 'foo' => 'bar', + * ], + * 'cookies' => [ + * 'foo' => 'bar', + * ], + * 'allow_redirects' => [ + * 'max' => 10, // allow at most 10 redirects. + * 'strict' => true, // use "strict" RFC compliant redirects. + * 'referer' => true, // add a Referer header + * 'protocols' => ['https'] // only allow https URLs + * ], + * 'sink' => '/path/to/file', // save to a file or a stream + * 'verify' => true, // bool or string to CA file + * 'debug' => true, + * 'timeout' => 5, + * @return IResponse + * @throws \Exception If the request could not get completed + */ + public function get(string $uri, array $options = []): IResponse { + $this->preventLocalAddress($uri, $options); + $response = $this->client->request('get', $uri, $this->buildRequestOptions($options)); + $isStream = isset($options['stream']) && $options['stream']; + return new Response($response, $isStream); + } + + /** + * Sends a HEAD request + * + * @param string $uri + * @param array $options Array such as + * 'headers' => [ + * 'foo' => 'bar', + * ], + * 'cookies' => [ + * 'foo' => 'bar', + * ], + * 'allow_redirects' => [ + * 'max' => 10, // allow at most 10 redirects. + * 'strict' => true, // use "strict" RFC compliant redirects. + * 'referer' => true, // add a Referer header + * 'protocols' => ['https'] // only allow https URLs + * ], + * 'sink' => '/path/to/file', // save to a file or a stream + * 'verify' => true, // bool or string to CA file + * 'debug' => true, + * 'timeout' => 5, + * @return IResponse + * @throws \Exception If the request could not get completed + */ + public function head(string $uri, array $options = []): IResponse { + $this->preventLocalAddress($uri, $options); + $response = $this->client->request('head', $uri, $this->buildRequestOptions($options)); + return new Response($response); + } + + /** + * Sends a POST request + * + * @param string $uri + * @param array $options Array such as + * 'body' => [ + * 'field' => 'abc', + * 'other_field' => '123', + * 'file_name' => fopen('/path/to/file', 'r'), + * ], + * 'headers' => [ + * 'foo' => 'bar', + * ], + * 'cookies' => [ + * 'foo' => 'bar', + * ], + * 'allow_redirects' => [ + * 'max' => 10, // allow at most 10 redirects. + * 'strict' => true, // use "strict" RFC compliant redirects. + * 'referer' => true, // add a Referer header + * 'protocols' => ['https'] // only allow https URLs + * ], + * 'sink' => '/path/to/file', // save to a file or a stream + * 'verify' => true, // bool or string to CA file + * 'debug' => true, + * 'timeout' => 5, + * @return IResponse + * @throws \Exception If the request could not get completed + */ + public function post(string $uri, array $options = []): IResponse { + $this->preventLocalAddress($uri, $options); + + if (isset($options['body']) && is_array($options['body'])) { + $options['form_params'] = $options['body']; + unset($options['body']); + } + $response = $this->client->request('post', $uri, $this->buildRequestOptions($options)); + $isStream = isset($options['stream']) && $options['stream']; + return new Response($response, $isStream); + } + + /** + * Sends a PUT request + * + * @param string $uri + * @param array $options Array such as + * 'body' => [ + * 'field' => 'abc', + * 'other_field' => '123', + * 'file_name' => fopen('/path/to/file', 'r'), + * ], + * 'headers' => [ + * 'foo' => 'bar', + * ], + * 'cookies' => [ + * 'foo' => 'bar', + * ], + * 'allow_redirects' => [ + * 'max' => 10, // allow at most 10 redirects. + * 'strict' => true, // use "strict" RFC compliant redirects. + * 'referer' => true, // add a Referer header + * 'protocols' => ['https'] // only allow https URLs + * ], + * 'sink' => '/path/to/file', // save to a file or a stream + * 'verify' => true, // bool or string to CA file + * 'debug' => true, + * 'timeout' => 5, + * @return IResponse + * @throws \Exception If the request could not get completed + */ + public function put(string $uri, array $options = []): IResponse { + $this->preventLocalAddress($uri, $options); + $response = $this->client->request('put', $uri, $this->buildRequestOptions($options)); + return new Response($response); + } + + /** + * Sends a PATCH request + * + * @param string $uri + * @param array $options Array such as + * 'body' => [ + * 'field' => 'abc', + * 'other_field' => '123', + * 'file_name' => fopen('/path/to/file', 'r'), + * ], + * 'headers' => [ + * 'foo' => 'bar', + * ], + * 'cookies' => [ + * 'foo' => 'bar', + * ], + * 'allow_redirects' => [ + * 'max' => 10, // allow at most 10 redirects. + * 'strict' => true, // use "strict" RFC compliant redirects. + * 'referer' => true, // add a Referer header + * 'protocols' => ['https'] // only allow https URLs + * ], + * 'sink' => '/path/to/file', // save to a file or a stream + * 'verify' => true, // bool or string to CA file + * 'debug' => true, + * 'timeout' => 5, + * @return IResponse + * @throws \Exception If the request could not get completed + */ + public function patch(string $uri, array $options = []): IResponse { + $this->preventLocalAddress($uri, $options); + $response = $this->client->request('patch', $uri, $this->buildRequestOptions($options)); + return new Response($response); + } + + /** + * Sends a DELETE request + * + * @param string $uri + * @param array $options Array such as + * 'body' => [ + * 'field' => 'abc', + * 'other_field' => '123', + * 'file_name' => fopen('/path/to/file', 'r'), + * ], + * 'headers' => [ + * 'foo' => 'bar', + * ], + * 'cookies' => [ + * 'foo' => 'bar', + * ], + * 'allow_redirects' => [ + * 'max' => 10, // allow at most 10 redirects. + * 'strict' => true, // use "strict" RFC compliant redirects. + * 'referer' => true, // add a Referer header + * 'protocols' => ['https'] // only allow https URLs + * ], + * 'sink' => '/path/to/file', // save to a file or a stream + * 'verify' => true, // bool or string to CA file + * 'debug' => true, + * 'timeout' => 5, + * @return IResponse + * @throws \Exception If the request could not get completed + */ + public function delete(string $uri, array $options = []): IResponse { + $this->preventLocalAddress($uri, $options); + $response = $this->client->request('delete', $uri, $this->buildRequestOptions($options)); + return new Response($response); + } + + /** + * Sends an OPTIONS request + * + * @param string $uri + * @param array $options Array such as + * 'body' => [ + * 'field' => 'abc', + * 'other_field' => '123', + * 'file_name' => fopen('/path/to/file', 'r'), + * ], + * 'headers' => [ + * 'foo' => 'bar', + * ], + * 'cookies' => [ + * 'foo' => 'bar', + * ], + * 'allow_redirects' => [ + * 'max' => 10, // allow at most 10 redirects. + * 'strict' => true, // use "strict" RFC compliant redirects. + * 'referer' => true, // add a Referer header + * 'protocols' => ['https'] // only allow https URLs + * ], + * 'sink' => '/path/to/file', // save to a file or a stream + * 'verify' => true, // bool or string to CA file + * 'debug' => true, + * 'timeout' => 5, + * @return IResponse + * @throws \Exception If the request could not get completed + */ + public function options(string $uri, array $options = []): IResponse { + $this->preventLocalAddress($uri, $options); + $response = $this->client->request('options', $uri, $this->buildRequestOptions($options)); + return new Response($response); + } + + /** + * Get the response of a Throwable thrown by the request methods when possible + * + * @param \Throwable $e + * @return IResponse + * @throws \Throwable When $e did not have a response + * @since 29.0.0 + */ + public function getResponseFromThrowable(\Throwable $e): IResponse { + if (method_exists($e, 'hasResponse') && method_exists($e, 'getResponse') && $e->hasResponse()) { + return new Response($e->getResponse()); + } + + throw $e; + } + + /** + * Sends a HTTP request + * + * @param string $method The HTTP method to use + * @param string $uri + * @param array $options Array such as + * 'query' => [ + * 'field' => 'abc', + * 'other_field' => '123', + * 'file_name' => fopen('/path/to/file', 'r'), + * ], + * 'headers' => [ + * 'foo' => 'bar', + * ], + * 'cookies' => [ + * 'foo' => 'bar', + * ], + * 'allow_redirects' => [ + * 'max' => 10, // allow at most 10 redirects. + * 'strict' => true, // use "strict" RFC compliant redirects. + * 'referer' => true, // add a Referer header + * 'protocols' => ['https'] // only allow https URLs + * ], + * 'sink' => '/path/to/file', // save to a file or a stream + * 'verify' => true, // bool or string to CA file + * 'debug' => true, + * 'timeout' => 5, + * @return IResponse + * @throws \Exception If the request could not get completed + */ + public function request(string $method, string $uri, array $options = []): IResponse { + $this->preventLocalAddress($uri, $options); + $response = $this->client->request($method, $uri, $this->buildRequestOptions($options)); + $isStream = isset($options['stream']) && $options['stream']; + return new Response($response, $isStream); + } + + protected function wrapGuzzlePromise(PromiseInterface $promise): IPromise { + return new GuzzlePromiseAdapter( + $promise, + $this->logger + ); + } + + /** + * Sends an asynchronous GET request + * + * @param string $uri + * @param array $options Array such as + * 'query' => [ + * 'field' => 'abc', + * 'other_field' => '123', + * 'file_name' => fopen('/path/to/file', 'r'), + * ], + * 'headers' => [ + * 'foo' => 'bar', + * ], + * 'cookies' => [ + * 'foo' => 'bar', + * ], + * 'allow_redirects' => [ + * 'max' => 10, // allow at most 10 redirects. + * 'strict' => true, // use "strict" RFC compliant redirects. + * 'referer' => true, // add a Referer header + * 'protocols' => ['https'] // only allow https URLs + * ], + * 'sink' => '/path/to/file', // save to a file or a stream + * 'verify' => true, // bool or string to CA file + * 'debug' => true, + * 'timeout' => 5, + * @return IPromise + */ + public function getAsync(string $uri, array $options = []): IPromise { + $this->preventLocalAddress($uri, $options); + $response = $this->client->requestAsync('get', $uri, $this->buildRequestOptions($options)); + return $this->wrapGuzzlePromise($response); + } + + /** + * Sends an asynchronous HEAD request + * + * @param string $uri + * @param array $options Array such as + * 'headers' => [ + * 'foo' => 'bar', + * ], + * 'cookies' => [ + * 'foo' => 'bar', + * ], + * 'allow_redirects' => [ + * 'max' => 10, // allow at most 10 redirects. + * 'strict' => true, // use "strict" RFC compliant redirects. + * 'referer' => true, // add a Referer header + * 'protocols' => ['https'] // only allow https URLs + * ], + * 'sink' => '/path/to/file', // save to a file or a stream + * 'verify' => true, // bool or string to CA file + * 'debug' => true, + * 'timeout' => 5, + * @return IPromise + */ + public function headAsync(string $uri, array $options = []): IPromise { + $this->preventLocalAddress($uri, $options); + $response = $this->client->requestAsync('head', $uri, $this->buildRequestOptions($options)); + return $this->wrapGuzzlePromise($response); + } + + /** + * Sends an asynchronous POST request + * + * @param string $uri + * @param array $options Array such as + * 'body' => [ + * 'field' => 'abc', + * 'other_field' => '123', + * 'file_name' => fopen('/path/to/file', 'r'), + * ], + * 'headers' => [ + * 'foo' => 'bar', + * ], + * 'cookies' => [ + * 'foo' => 'bar', + * ], + * 'allow_redirects' => [ + * 'max' => 10, // allow at most 10 redirects. + * 'strict' => true, // use "strict" RFC compliant redirects. + * 'referer' => true, // add a Referer header + * 'protocols' => ['https'] // only allow https URLs + * ], + * 'sink' => '/path/to/file', // save to a file or a stream + * 'verify' => true, // bool or string to CA file + * 'debug' => true, + * 'timeout' => 5, + * @return IPromise + */ + public function postAsync(string $uri, array $options = []): IPromise { + $this->preventLocalAddress($uri, $options); + + if (isset($options['body']) && is_array($options['body'])) { + $options['form_params'] = $options['body']; + unset($options['body']); + } + + return $this->wrapGuzzlePromise($this->client->requestAsync('post', $uri, $this->buildRequestOptions($options))); + } + + /** + * Sends an asynchronous PUT request + * + * @param string $uri + * @param array $options Array such as + * 'body' => [ + * 'field' => 'abc', + * 'other_field' => '123', + * 'file_name' => fopen('/path/to/file', 'r'), + * ], + * 'headers' => [ + * 'foo' => 'bar', + * ], + * 'cookies' => [ + * 'foo' => 'bar', + * ], + * 'allow_redirects' => [ + * 'max' => 10, // allow at most 10 redirects. + * 'strict' => true, // use "strict" RFC compliant redirects. + * 'referer' => true, // add a Referer header + * 'protocols' => ['https'] // only allow https URLs + * ], + * 'sink' => '/path/to/file', // save to a file or a stream + * 'verify' => true, // bool or string to CA file + * 'debug' => true, + * 'timeout' => 5, + * @return IPromise + */ + public function putAsync(string $uri, array $options = []): IPromise { + $this->preventLocalAddress($uri, $options); + $response = $this->client->requestAsync('put', $uri, $this->buildRequestOptions($options)); + return $this->wrapGuzzlePromise($response); + } + + /** + * Sends an asynchronous DELETE request + * + * @param string $uri + * @param array $options Array such as + * 'body' => [ + * 'field' => 'abc', + * 'other_field' => '123', + * 'file_name' => fopen('/path/to/file', 'r'), + * ], + * 'headers' => [ + * 'foo' => 'bar', + * ], + * 'cookies' => [ + * 'foo' => 'bar', + * ], + * 'allow_redirects' => [ + * 'max' => 10, // allow at most 10 redirects. + * 'strict' => true, // use "strict" RFC compliant redirects. + * 'referer' => true, // add a Referer header + * 'protocols' => ['https'] // only allow https URLs + * ], + * 'sink' => '/path/to/file', // save to a file or a stream + * 'verify' => true, // bool or string to CA file + * 'debug' => true, + * 'timeout' => 5, + * @return IPromise + */ + public function deleteAsync(string $uri, array $options = []): IPromise { + $this->preventLocalAddress($uri, $options); + $response = $this->client->requestAsync('delete', $uri, $this->buildRequestOptions($options)); + return $this->wrapGuzzlePromise($response); + } + + /** + * Sends an asynchronous OPTIONS request + * + * @param string $uri + * @param array $options Array such as + * 'body' => [ + * 'field' => 'abc', + * 'other_field' => '123', + * 'file_name' => fopen('/path/to/file', 'r'), + * ], + * 'headers' => [ + * 'foo' => 'bar', + * ], + * 'cookies' => [ + * 'foo' => 'bar', + * ], + * 'allow_redirects' => [ + * 'max' => 10, // allow at most 10 redirects. + * 'strict' => true, // use "strict" RFC compliant redirects. + * 'referer' => true, // add a Referer header + * 'protocols' => ['https'] // only allow https URLs + * ], + * 'sink' => '/path/to/file', // save to a file or a stream + * 'verify' => true, // bool or string to CA file + * 'debug' => true, + * 'timeout' => 5, + * @return IPromise + */ + public function optionsAsync(string $uri, array $options = []): IPromise { + $this->preventLocalAddress($uri, $options); + $response = $this->client->requestAsync('options', $uri, $this->buildRequestOptions($options)); + return $this->wrapGuzzlePromise($response); + } +} diff --git a/lib/private/Http/Client/ClientService.php b/lib/private/Http/Client/ClientService.php new file mode 100644 index 00000000000..b719f3d369d --- /dev/null +++ b/lib/private/Http/Client/ClientService.php @@ -0,0 +1,79 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Http\Client; + +use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\Handler\CurlHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; +use OCP\Diagnostics\IEventLogger; +use OCP\Http\Client\IClient; +use OCP\Http\Client\IClientService; +use OCP\ICertificateManager; +use OCP\IConfig; +use OCP\Security\IRemoteHostValidator; +use Psr\Http\Message\RequestInterface; +use Psr\Log\LoggerInterface; + +/** + * Class ClientService + * + * @package OC\Http + */ +class ClientService implements IClientService { + /** @var IConfig */ + private $config; + /** @var ICertificateManager */ + private $certificateManager; + /** @var DnsPinMiddleware */ + private $dnsPinMiddleware; + private IRemoteHostValidator $remoteHostValidator; + private IEventLogger $eventLogger; + + public function __construct( + IConfig $config, + ICertificateManager $certificateManager, + DnsPinMiddleware $dnsPinMiddleware, + IRemoteHostValidator $remoteHostValidator, + IEventLogger $eventLogger, + protected LoggerInterface $logger, + ) { + $this->config = $config; + $this->certificateManager = $certificateManager; + $this->dnsPinMiddleware = $dnsPinMiddleware; + $this->remoteHostValidator = $remoteHostValidator; + $this->eventLogger = $eventLogger; + } + + /** + * @return Client + */ + public function newClient(): IClient { + $handler = new CurlHandler(); + $stack = HandlerStack::create($handler); + if ($this->config->getSystemValueBool('dns_pinning', true)) { + $stack->push($this->dnsPinMiddleware->addDnsPinning()); + } + $stack->push(Middleware::tap(function (RequestInterface $request) { + $this->eventLogger->start('http:request', $request->getMethod() . ' request to ' . $request->getRequestTarget()); + }, function () { + $this->eventLogger->end('http:request'); + }), 'event logger'); + + $client = new GuzzleClient(['handler' => $stack]); + + return new Client( + $this->config, + $this->certificateManager, + $client, + $this->remoteHostValidator, + $this->logger, + ); + } +} diff --git a/lib/private/Http/Client/DnsPinMiddleware.php b/lib/private/Http/Client/DnsPinMiddleware.php new file mode 100644 index 00000000000..96e0f71adbe --- /dev/null +++ b/lib/private/Http/Client/DnsPinMiddleware.php @@ -0,0 +1,145 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Http\Client; + +use OC\Net\IpAddressClassifier; +use OCP\Http\Client\LocalServerException; +use Psr\Http\Message\RequestInterface; + +class DnsPinMiddleware { + + public function __construct( + private NegativeDnsCache $negativeDnsCache, + private IpAddressClassifier $ipAddressClassifier, + ) { + } + + /** + * Fetch soa record for a target + */ + private function soaRecord(string $target): ?array { + $labels = explode('.', $target); + + $top = count($labels) >= 2 ? array_pop($labels) : ''; + $second = array_pop($labels); + + $hostname = $second . '.' . $top; + $responses = $this->dnsGetRecord($hostname, DNS_SOA); + + if ($responses === false || count($responses) === 0) { + return null; + } + + return reset($responses); + } + + private function dnsResolve(string $target, int $recursionCount) : array { + if ($recursionCount >= 10) { + return []; + } + + $recursionCount++; + $targetIps = []; + + $soaDnsEntry = $this->soaRecord($target); + $dnsNegativeTtl = $soaDnsEntry['minimum-ttl'] ?? null; + $canHaveCnameRecord = true; + + $dnsTypes = \defined('AF_INET6') || @inet_pton('::1') + ? [DNS_A, DNS_AAAA, DNS_CNAME] + : [DNS_A, DNS_CNAME]; + foreach ($dnsTypes as $dnsType) { + if ($canHaveCnameRecord === false && $dnsType === DNS_CNAME) { + continue; + } + + if ($this->negativeDnsCache->isNegativeCached($target, $dnsType)) { + continue; + } + + $dnsResponses = $this->dnsGetRecord($target, $dnsType); + if ($dnsResponses !== false && count($dnsResponses) > 0) { + foreach ($dnsResponses as $dnsResponse) { + if (isset($dnsResponse['ip'])) { + $targetIps[] = $dnsResponse['ip']; + $canHaveCnameRecord = false; + } elseif (isset($dnsResponse['ipv6'])) { + $targetIps[] = $dnsResponse['ipv6']; + $canHaveCnameRecord = false; + } elseif (isset($dnsResponse['target']) && $canHaveCnameRecord) { + $targetIps = array_merge($targetIps, $this->dnsResolve($dnsResponse['target'], $recursionCount)); + } + } + } elseif ($dnsNegativeTtl !== null) { + $this->negativeDnsCache->setNegativeCacheForDnsType($target, $dnsType, $dnsNegativeTtl); + } + } + + return $targetIps; + } + + /** + * Wrapper for dns_get_record + */ + protected function dnsGetRecord(string $hostname, int $type): array|false { + return \dns_get_record($hostname, $type); + } + + public function addDnsPinning(): callable { + return function (callable $handler) { + return function ( + RequestInterface $request, + array $options, + ) use ($handler) { + if ($options['nextcloud']['allow_local_address'] === true) { + return $handler($request, $options); + } + + $hostName = $request->getUri()->getHost(); + $port = $request->getUri()->getPort(); + + $ports = [ + '80', + '443', + ]; + + if ($port !== null) { + $ports[] = (string)$port; + } + + $targetIps = $this->dnsResolve(idn_to_utf8($hostName), 0); + + if (empty($targetIps)) { + throw new LocalServerException('No DNS record found for ' . $hostName); + } + + $curlResolves = []; + + foreach ($ports as $port) { + $curlResolves["$hostName:$port"] = []; + + foreach ($targetIps as $ip) { + if ($this->ipAddressClassifier->isLocalAddress($ip)) { + // TODO: continue with all non-local IPs? + throw new LocalServerException('Host "' . $ip . '" (' . $hostName . ':' . $port . ') violates local access rules'); + } + $curlResolves["$hostName:$port"][] = $ip; + } + } + + // Coalesce the per-host:port ips back into a comma separated list + foreach ($curlResolves as $hostport => $ips) { + $options['curl'][CURLOPT_RESOLVE][] = "$hostport:" . implode(',', $ips); + } + + return $handler($request, $options); + }; + }; + } +} diff --git a/lib/private/Http/Client/GuzzlePromiseAdapter.php b/lib/private/Http/Client/GuzzlePromiseAdapter.php new file mode 100644 index 00000000000..03a9ed9a599 --- /dev/null +++ b/lib/private/Http/Client/GuzzlePromiseAdapter.php @@ -0,0 +1,124 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Http\Client; + +use Exception; +use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Promise\PromiseInterface; +use LogicException; +use OCP\Http\Client\IPromise; +use OCP\Http\Client\IResponse; +use Psr\Http\Message\ResponseInterface; +use Psr\Log\LoggerInterface; + +/** + * A wrapper around Guzzle's PromiseInterface + * + * @see \GuzzleHttp\Promise\PromiseInterface + * @since 28.0.0 + */ +class GuzzlePromiseAdapter implements IPromise { + public function __construct( + protected PromiseInterface $promise, + protected LoggerInterface $logger, + ) { + } + + /** + * Appends fulfillment and rejection handlers to the promise, and returns + * a new promise resolving to the return value of the called handler. + * + * @param ?callable(IResponse): void $onFulfilled Invoked when the promise fulfills. Gets an \OCP\Http\Client\IResponse passed in as argument + * @param ?callable(Exception): void $onRejected Invoked when the promise is rejected. Gets an \Exception passed in as argument + * + * @return IPromise + * @since 28.0.0 + */ + public function then( + ?callable $onFulfilled = null, + ?callable $onRejected = null, + ): IPromise { + if ($onFulfilled !== null) { + $wrappedOnFulfilled = static function (ResponseInterface $response) use ($onFulfilled) { + $onFulfilled(new Response($response)); + }; + } else { + $wrappedOnFulfilled = null; + } + + if ($onRejected !== null) { + $wrappedOnRejected = static function (RequestException $e) use ($onRejected) { + $onRejected($e); + }; + } else { + $wrappedOnRejected = null; + } + + $this->promise->then($wrappedOnFulfilled, $wrappedOnRejected); + return $this; + } + + /** + * Get the state of the promise ("pending", "rejected", or "fulfilled"). + * + * The three states can be checked against the constants defined: + * STATE_PENDING, STATE_FULFILLED, and STATE_REJECTED. + * + * @return IPromise::STATE_* + * @since 28.0.0 + */ + public function getState(): string { + $state = $this->promise->getState(); + if ($state === PromiseInterface::FULFILLED) { + return self::STATE_FULFILLED; + } + if ($state === PromiseInterface::REJECTED) { + return self::STATE_REJECTED; + } + if ($state === PromiseInterface::PENDING) { + return self::STATE_PENDING; + } + + $this->logger->error('Unexpected promise state "{state}" returned by Guzzle', [ + 'state' => $state, + ]); + return self::STATE_PENDING; + } + + /** + * Cancels the promise if possible. + * + * @link https://github.com/promises-aplus/cancellation-spec/issues/7 + * @since 28.0.0 + */ + public function cancel(): void { + $this->promise->cancel(); + } + + /** + * Waits until the promise completes if possible. + * + * Pass $unwrap as true to unwrap the result of the promise, either + * returning the resolved value or throwing the rejected exception. + * + * If the promise cannot be waited on, then the promise will be rejected. + * + * @param bool $unwrap + * + * @return mixed + * + * @throws LogicException if the promise has no wait function or if the + * promise does not settle after waiting. + * @since 28.0.0 + */ + public function wait(bool $unwrap = true): mixed { + return $this->promise->wait($unwrap); + } +} diff --git a/lib/private/Http/Client/NegativeDnsCache.php b/lib/private/Http/Client/NegativeDnsCache.php new file mode 100644 index 00000000000..ca8a477d6be --- /dev/null +++ b/lib/private/Http/Client/NegativeDnsCache.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Http\Client; + +use OCP\ICache; +use OCP\ICacheFactory; + +class NegativeDnsCache { + /** @var ICache */ + private $cache; + + public function __construct(ICacheFactory $memcache) { + $this->cache = $memcache->createLocal('NegativeDnsCache'); + } + + private function createCacheKey(string $domain, int $type) : string { + return $domain . '-' . (string)$type; + } + + public function setNegativeCacheForDnsType(string $domain, int $type, int $ttl) : void { + $this->cache->set($this->createCacheKey($domain, $type), 'true', $ttl); + } + + public function isNegativeCached(string $domain, int $type) : bool { + return (bool)$this->cache->hasKey($this->createCacheKey($domain, $type)); + } +} diff --git a/lib/private/Http/Client/Response.php b/lib/private/Http/Client/Response.php new file mode 100644 index 00000000000..1e4cb3b8fa2 --- /dev/null +++ b/lib/private/Http/Client/Response.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Http\Client; + +use OCP\Http\Client\IResponse; +use Psr\Http\Message\ResponseInterface; + +class Response implements IResponse { + private ResponseInterface $response; + private bool $stream; + + public function __construct(ResponseInterface $response, bool $stream = false) { + $this->response = $response; + $this->stream = $stream; + } + + public function getBody() { + return $this->stream + ? $this->response->getBody()->detach() + :$this->response->getBody()->getContents(); + } + + public function getStatusCode(): int { + return $this->response->getStatusCode(); + } + + public function getHeader(string $key): string { + $headers = $this->response->getHeader($key); + + if (count($headers) === 0) { + return ''; + } + + return $headers[0]; + } + + public function getHeaders(): array { + return $this->response->getHeaders(); + } +} diff --git a/lib/private/Http/CookieHelper.php b/lib/private/Http/CookieHelper.php new file mode 100644 index 00000000000..9d07ff4534c --- /dev/null +++ b/lib/private/Http/CookieHelper.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Http; + +class CookieHelper { + public const SAMESITE_NONE = 0; + public const SAMESITE_LAX = 1; + public const SAMESITE_STRICT = 2; + + public static function setCookie(string $name, + string $value = '', + int $maxAge = 0, + string $path = '', + string $domain = '', + bool $secure = false, + bool $httponly = false, + int $samesite = self::SAMESITE_NONE) { + $header = sprintf( + 'Set-Cookie: %s=%s', + $name, + rawurlencode($value) + ); + + if ($path !== '') { + $header .= sprintf('; Path=%s', $path); + } + + if ($domain !== '') { + $header .= sprintf('; Domain=%s', $domain); + } + + if ($maxAge > 0) { + $header .= sprintf('; Max-Age=%d', $maxAge); + } + + if ($secure) { + $header .= '; Secure'; + } + + if ($httponly) { + $header .= '; HttpOnly'; + } + + if ($samesite === self::SAMESITE_LAX) { + $header .= '; SameSite=Lax'; + } elseif ($samesite === self::SAMESITE_STRICT) { + $header .= '; SameSite=Strict'; + } + + header($header, false); + } +} diff --git a/lib/private/Http/WellKnown/RequestManager.php b/lib/private/Http/WellKnown/RequestManager.php new file mode 100644 index 00000000000..3624bf73962 --- /dev/null +++ b/lib/private/Http/WellKnown/RequestManager.php @@ -0,0 +1,109 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Http\WellKnown; + +use OC\AppFramework\Bootstrap\Coordinator; +use OC\AppFramework\Bootstrap\ServiceRegistration; +use OCP\AppFramework\QueryException; +use OCP\Http\WellKnown\IHandler; +use OCP\Http\WellKnown\IRequestContext; +use OCP\Http\WellKnown\IResponse; +use OCP\Http\WellKnown\JrdResponse; +use OCP\IRequest; +use OCP\IServerContainer; +use Psr\Log\LoggerInterface; +use RuntimeException; +use function array_reduce; + +class RequestManager { + /** @var Coordinator */ + private $coordinator; + + /** @var IServerContainer */ + private $container; + + /** @var LoggerInterface */ + private $logger; + + public function __construct(Coordinator $coordinator, + IServerContainer $container, + LoggerInterface $logger) { + $this->coordinator = $coordinator; + $this->container = $container; + $this->logger = $logger; + } + + public function process(string $service, IRequest $request): ?IResponse { + $handlers = $this->loadHandlers(); + $context = new class($request) implements IRequestContext { + /** @var IRequest */ + private $request; + + public function __construct(IRequest $request) { + $this->request = $request; + } + + public function getHttpRequest(): IRequest { + return $this->request; + } + }; + + $subject = $request->getParam('resource'); + $initialResponse = new JrdResponse($subject ?? ''); + $finalResponse = array_reduce($handlers, function (?IResponse $previousResponse, IHandler $handler) use ($context, $service) { + return $handler->handle($service, $context, $previousResponse); + }, $initialResponse); + + if ($finalResponse instanceof JrdResponse && $finalResponse->isEmpty()) { + return null; + } + + return $finalResponse; + } + + /** + * @return IHandler[] + */ + private function loadHandlers(): array { + $context = $this->coordinator->getRegistrationContext(); + + if ($context === null) { + throw new RuntimeException('Well known handlers requested before the apps had been fully registered'); + } + + $registrations = $context->getWellKnownHandlers(); + $this->logger->debug(count($registrations) . ' well known handlers registered'); + + return array_filter( + array_map(function (ServiceRegistration $registration) { + /** @var ServiceRegistration<IHandler> $registration */ + $class = $registration->getService(); + + try { + $handler = $this->container->get($class); + + if (!($handler) instanceof IHandler) { + $this->logger->error("Well known handler $class is invalid"); + + return null; + } + + return $handler; + } catch (QueryException $e) { + $this->logger->error("Could not load well known handler $class", [ + 'exception' => $e, + 'app' => $registration->getAppId(), + ]); + + return null; + } + }, $registrations) + ); + } +} |