aboutsummaryrefslogtreecommitdiffstats
path: root/lib/private/Http
diff options
context:
space:
mode:
Diffstat (limited to 'lib/private/Http')
-rw-r--r--lib/private/Http/Client/Client.php685
-rw-r--r--lib/private/Http/Client/ClientService.php79
-rw-r--r--lib/private/Http/Client/DnsPinMiddleware.php145
-rw-r--r--lib/private/Http/Client/GuzzlePromiseAdapter.php124
-rw-r--r--lib/private/Http/Client/NegativeDnsCache.php33
-rw-r--r--lib/private/Http/Client/Response.php46
-rw-r--r--lib/private/Http/CookieHelper.php58
-rw-r--r--lib/private/Http/WellKnown/RequestManager.php109
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)
+ );
+ }
+}