Skip to content
141 changes: 100 additions & 41 deletions lib/private/Http/Client/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use OCP\ServerVersion;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
use Psr\Log\LoggerInterface;
use function parse_url;

Expand All @@ -41,67 +42,125 @@ public function __construct(
}

private function buildRequestOptions(array $options): array {
$proxy = $this->getProxyUri();

$defaults = [
RequestOptions::VERIFY => $this->getCertBundle(),
RequestOptions::TIMEOUT => IClient::DEFAULT_REQUEST_TIMEOUT,
// Prefer HTTP/2 globally (PSR-7 request version)
RequestOptions::VERSION => '2.0',
];
$defaults['curl'][\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_2TLS;

$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): void {
$this->preventLocalAddress($uri->__toString(), $options);
};

$defaults[RequestOptions::ALLOW_REDIRECTS] = [
'on_redirect' => $onRedirectFunction
];
$this->applyHttp2Defaults($defaults);
$this->applyLocalAddressProtection($defaults, $options);
$this->applyProxyOption($defaults);

$options = array_merge($defaults, $options);
$options[RequestOptions::HEADERS] ??= [];

$this->ensureDefaultUserAgent($options);
$this->ensureDefaultAcceptEncoding($options);
$this->normalizeLegacySaveToOption($options);

return $options;
}

/**
* Propagate local-address policy into request options and validate redirect targets.
*/
private function applyLocalAddressProtection(array &$defaults, array &$options): void {
$allowLocalAddress = $this->isLocalAddressAllowed($options);
$options['nextcloud']['allow_local_address'] = $allowLocalAddress;

if ($allowLocalAddress) {
return;
}

$defaults[RequestOptions::ALLOW_REDIRECTS] = [
'on_redirect' => function (
RequestInterface $_request,
ResponseInterface $_response,
UriInterface $uri,
) use ($options): void {
$this->preventLocalAddress((string)$uri, $options);
},
];
}

private function applyProxyOption(array &$defaults): void {
$proxy = $this->getProxyUri();

// 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);
private function ensureDefaultUserAgent(array &$options): void {
if (isset($options[RequestOptions::HEADERS]['User-Agent'])) {
return;
}

$userAgent = 'Nextcloud-Server-Crawler/' . $this->serverVersion->getVersionString();
$overwriteCliUrl = $this->config->getSystemValueString('overwrite.cli.url');
$addUrl = !empty($overwriteCliUrl) && $this->config->getSystemValueBool('http_client_add_user_agent_url', false);

if (!isset($options[RequestOptions::HEADERS]['User-Agent'])) {
$userAgent = 'Nextcloud-Server-Crawler/' . $this->serverVersion->getVersionString();
$overwriteCliUrl = $this->config->getSystemValueString('overwrite.cli.url');
if ($this->config->getSystemValueBool('http_client_add_user_agent_url') && !empty($overwriteCliUrl)) {
$userAgent .= '; +' . rtrim($overwriteCliUrl, '/');
}
$options[RequestOptions::HEADERS]['User-Agent'] = $userAgent;
// Optionally append the instance URL to the crawler user agent.
if ($addUrl) {
$userAgent .= '; +' . rtrim($overwriteCliUrl, '/');
}

// Ensure headers array exists and set Accept-Encoding only if not present
$headers = $options[RequestOptions::HEADERS] ?? [];
if (!isset($headers['Accept-Encoding'])) {
$acceptEnc = 'gzip';
if (function_exists('brotli_uncompress')) {
$acceptEnc = 'br, ' . $acceptEnc;
}
$options[RequestOptions::HEADERS] = $headers; // ensure headers are present
$options[RequestOptions::HEADERS]['Accept-Encoding'] = $acceptEnc;
$options[RequestOptions::HEADERS]['User-Agent'] = $userAgent;
}

private function ensureDefaultAcceptEncoding(array &$options): void {
if (isset($options[RequestOptions::HEADERS]['Accept-Encoding'])) {
return;
}

// Fallback for save_to
if (isset($options['save_to'])) {
$options['sink'] = $options['save_to'];
unset($options['save_to']);
$acceptEncoding = 'gzip';
if (function_exists('brotli_uncompress')) {
$acceptEncoding = 'br, ' . $acceptEncoding;
}

return $options;
$options[RequestOptions::HEADERS]['Accept-Encoding'] = $acceptEncoding;
}

private function normalizeLegacySaveToOption(array &$options): void {
if (!isset($options['save_to'])) {
return;
}

// Support legacy 'save_to' by mapping it to Guzzle's 'sink'.
$options['sink'] = $options['save_to'];
unset($options['save_to']);
}

private function applyHttp2Defaults(array &$defaults): void {
if (!$this->supportsHttp2()) {
return;
}

// Prefer HTTP/2 when supported by the local libcurl build.
$defaults[RequestOptions::VERSION] = '2.0';

if (\defined('CURL_HTTP_VERSION_2TLS')) {
$defaults['curl'][\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_2TLS;
return;
}

if (\defined('CURL_HTTP_VERSION_2_0')) {
$defaults['curl'][\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_2_0;
}
}

protected function supportsHttp2(): bool {
if (!\function_exists('curl_version') || !\defined('CURL_VERSION_HTTP2')) {
return false;
}

$curlVersion = \curl_version();
$features = $curlVersion['features'] ?? 0;

return ($features & \CURL_VERSION_HTTP2) === \CURL_VERSION_HTTP2;
}

private function getCertBundle(): string {
Expand Down Expand Up @@ -155,7 +214,7 @@ private function getProxyUri(): ?array {
return $proxy;
}

private function isLocalAddressAllowed(array $options) : bool {
private function isLocalAddressAllowed(array $options): bool {
if (($options['nextcloud']['allow_local_address'] ?? false)
|| $this->config->getSystemValueBool('allow_local_remote_servers', false)) {
return true;
Expand Down
Loading
Loading