Check all remotes for local access

Signed-off-by: Joas Schilling <coding@schilljs.com>
This commit is contained in:
Joas Schilling 2020-03-24 14:19:57 +01:00
parent d7a74d0e35
commit 5e402f8aae
No known key found for this signature in database
GPG Key ID: 7076EA9751AACDDA
9 changed files with 242 additions and 57 deletions

View File

@ -32,6 +32,7 @@ use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware; use GuzzleHttp\Middleware;
use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\CalDAV\CalDavBackend;
use OCP\Http\Client\IClientService; use OCP\Http\Client\IClientService;
use OCP\Http\Client\LocalServerException;
use OCP\IConfig; use OCP\IConfig;
use OCP\ILogger; use OCP\ILogger;
use Psr\Http\Message\RequestInterface; use Psr\Http\Message\RequestInterface;
@ -215,48 +216,15 @@ class RefreshWebcalService {
return null; return null;
} }
if ($allowLocalAccess !== 'yes') {
$host = strtolower(parse_url($url, PHP_URL_HOST));
// remove brackets from IPv6 addresses
if (strpos($host, '[') === 0 && substr($host, -1) === ']') {
$host = substr($host, 1, -1);
}
// Disallow localhost and local network
if ($host === 'localhost' || substr($host, -6) === '.local' || substr($host, -10) === '.localhost') {
$this->logger->warning("Subscription $subscriptionId was not refreshed because it violates local access rules");
return null;
}
// Disallow hostname only
if (substr_count($host, '.') === 0) {
$this->logger->warning("Subscription $subscriptionId was not refreshed because it violates local access rules");
return null;
}
if ((bool)filter_var($host, FILTER_VALIDATE_IP) && !filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
$this->logger->warning("Subscription $subscriptionId was not refreshed because it violates local access rules");
return null;
}
// Also check for IPv6 IPv4 nesting, because that's not covered by filter_var
if ((bool)filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) && substr_count($host, '.') > 0) {
$delimiter = strrpos($host, ':'); // Get last colon
$ipv4Address = substr($host, $delimiter + 1);
if (!filter_var($ipv4Address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
$this->logger->warning("Subscription $subscriptionId was not refreshed because it violates local access rules");
return null;
}
}
}
try { try {
$params = [ $params = [
'allow_redirects' => [ 'allow_redirects' => [
'redirects' => 10 'redirects' => 10
], ],
'handler' => $handlerStack, 'handler' => $handlerStack,
'nextcloud' => [
'allow_local_address' => $allowLocalAccess === 'yes',
]
]; ];
$user = parse_url($subscription['source'], PHP_URL_USER); $user = parse_url($subscription['source'], PHP_URL_USER);
@ -306,9 +274,18 @@ class RefreshWebcalService {
} }
return $vCalendar->serialize(); return $vCalendar->serialize();
} }
} catch (LocalServerException $ex) {
$this->logger->logException($ex, [
'message' => "Subscription $subscriptionId was not refreshed because it violates local access rules",
'level' => ILogger::WARN,
]);
return null;
} catch (Exception $ex) { } catch (Exception $ex) {
$this->logger->logException($ex); $this->logger->logException($ex, [
$this->logger->warning("Subscription $subscriptionId could not be refreshed due to a network error"); 'message' => "Subscription $subscriptionId could not be refreshed due to a network error",
'level' => ILogger::WARN,
]);
return null; return null;
} }

View File

@ -558,6 +558,13 @@ $CONFIG = [
*/ */
'proxyexclude' => [], 'proxyexclude' => [],
/**
* Allow remote servers with local addresses e.g. in federated shares, webcal services and more
*
* Defaults to false
*/
'allow_local_remote_servers' => true,
/** /**
* Deleted Items (trash bin) * Deleted Items (trash bin)
* *

View File

@ -323,6 +323,7 @@ return array(
'OCP\\Http\\Client\\IClient' => $baseDir . '/lib/public/Http/Client/IClient.php', 'OCP\\Http\\Client\\IClient' => $baseDir . '/lib/public/Http/Client/IClient.php',
'OCP\\Http\\Client\\IClientService' => $baseDir . '/lib/public/Http/Client/IClientService.php', 'OCP\\Http\\Client\\IClientService' => $baseDir . '/lib/public/Http/Client/IClientService.php',
'OCP\\Http\\Client\\IResponse' => $baseDir . '/lib/public/Http/Client/IResponse.php', 'OCP\\Http\\Client\\IResponse' => $baseDir . '/lib/public/Http/Client/IResponse.php',
'OCP\\Http\\Client\\LocalServerException' => $baseDir . '/lib/public/Http/Client/LocalServerException.php',
'OCP\\IAddressBook' => $baseDir . '/lib/public/IAddressBook.php', 'OCP\\IAddressBook' => $baseDir . '/lib/public/IAddressBook.php',
'OCP\\IAppConfig' => $baseDir . '/lib/public/IAppConfig.php', 'OCP\\IAppConfig' => $baseDir . '/lib/public/IAppConfig.php',
'OCP\\IAvatar' => $baseDir . '/lib/public/IAvatar.php', 'OCP\\IAvatar' => $baseDir . '/lib/public/IAvatar.php',

View File

@ -352,6 +352,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c
'OCP\\Http\\Client\\IClient' => __DIR__ . '/../../..' . '/lib/public/Http/Client/IClient.php', 'OCP\\Http\\Client\\IClient' => __DIR__ . '/../../..' . '/lib/public/Http/Client/IClient.php',
'OCP\\Http\\Client\\IClientService' => __DIR__ . '/../../..' . '/lib/public/Http/Client/IClientService.php', 'OCP\\Http\\Client\\IClientService' => __DIR__ . '/../../..' . '/lib/public/Http/Client/IClientService.php',
'OCP\\Http\\Client\\IResponse' => __DIR__ . '/../../..' . '/lib/public/Http/Client/IResponse.php', 'OCP\\Http\\Client\\IResponse' => __DIR__ . '/../../..' . '/lib/public/Http/Client/IResponse.php',
'OCP\\Http\\Client\\LocalServerException' => __DIR__ . '/../../..' . '/lib/public/Http/Client/LocalServerException.php',
'OCP\\IAddressBook' => __DIR__ . '/../../..' . '/lib/public/IAddressBook.php', 'OCP\\IAddressBook' => __DIR__ . '/../../..' . '/lib/public/IAddressBook.php',
'OCP\\IAppConfig' => __DIR__ . '/../../..' . '/lib/public/IAppConfig.php', 'OCP\\IAppConfig' => __DIR__ . '/../../..' . '/lib/public/IAppConfig.php',
'OCP\\IAvatar' => __DIR__ . '/../../..' . '/lib/public/IAvatar.php', 'OCP\\IAvatar' => __DIR__ . '/../../..' . '/lib/public/IAvatar.php',

View File

@ -34,8 +34,10 @@ use GuzzleHttp\Client as GuzzleClient;
use GuzzleHttp\RequestOptions; use GuzzleHttp\RequestOptions;
use OCP\Http\Client\IClient; use OCP\Http\Client\IClient;
use OCP\Http\Client\IResponse; use OCP\Http\Client\IResponse;
use OCP\Http\Client\LocalServerException;
use OCP\ICertificateManager; use OCP\ICertificateManager;
use OCP\IConfig; use OCP\IConfig;
use OCP\ILogger;
/** /**
* Class Client * Class Client
@ -47,20 +49,19 @@ class Client implements IClient {
private $client; private $client;
/** @var IConfig */ /** @var IConfig */
private $config; private $config;
/** @var ILogger */
private $logger;
/** @var ICertificateManager */ /** @var ICertificateManager */
private $certificateManager; private $certificateManager;
/**
* @param IConfig $config
* @param ICertificateManager $certificateManager
* @param GuzzleClient $client
*/
public function __construct( public function __construct(
IConfig $config, IConfig $config,
ILogger $logger,
ICertificateManager $certificateManager, ICertificateManager $certificateManager,
GuzzleClient $client GuzzleClient $client
) { ) {
$this->config = $config; $this->config = $config;
$this->logger = $logger;
$this->client = $client; $this->client = $client;
$this->certificateManager = $certificateManager; $this->certificateManager = $certificateManager;
} }
@ -144,6 +145,53 @@ class Client implements IClient {
return $proxy; return $proxy;
} }
protected function preventLocalAddress(string $uri, array $options): void {
if (($options['nextcloud']['allow_local_address'] ?? false) ||
$this->config->getSystemValueBool('allow_local_remote_servers', false)) {
return;
}
$host = parse_url($uri, PHP_URL_HOST);
if ($host === false) {
$this->logger->warning("Could not detect any host in $uri");
throw new LocalServerException('Could not detect any host');
}
$host = strtolower($host);
// remove brackets from IPv6 addresses
if (strpos($host, '[') === 0 && substr($host, -1) === ']') {
$host = substr($host, 1, -1);
}
// Disallow localhost and local network
if ($host === 'localhost' || substr($host, -6) === '.local' || substr($host, -10) === '.localhost') {
$this->logger->warning("Host $host was not connected to because it violates local access rules");
throw new LocalServerException('Host violates local access rules');
}
// Disallow hostname only
if (substr_count($host, '.') === 0) {
$this->logger->warning("Host $host was not connected to because it violates local access rules");
throw new LocalServerException('Host violates local access rules');
}
if ((bool)filter_var($host, FILTER_VALIDATE_IP) && !filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
$this->logger->warning("Host $host was not connected to because it violates local access rules");
throw new LocalServerException('Host violates local access rules');
}
// Also check for IPv6 IPv4 nesting, because that's not covered by filter_var
if ((bool)filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) && substr_count($host, '.') > 0) {
$delimiter = strrpos($host, ':'); // Get last colon
$ipv4Address = substr($host, $delimiter + 1);
if (!filter_var($ipv4Address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
$this->logger->warning("Host $host was not connected to because it violates local access rules");
throw new LocalServerException('Host violates local access rules');
}
}
}
/** /**
* Sends a GET request * Sends a GET request
* *
@ -174,6 +222,7 @@ class Client implements IClient {
* @throws \Exception If the request could not get completed * @throws \Exception If the request could not get completed
*/ */
public function get(string $uri, array $options = []): IResponse { public function get(string $uri, array $options = []): IResponse {
$this->preventLocalAddress($uri, $options);
$response = $this->client->request('get', $uri, $this->buildRequestOptions($options)); $response = $this->client->request('get', $uri, $this->buildRequestOptions($options));
$isStream = isset($options['stream']) && $options['stream']; $isStream = isset($options['stream']) && $options['stream'];
return new Response($response, $isStream); return new Response($response, $isStream);
@ -204,6 +253,7 @@ class Client implements IClient {
* @throws \Exception If the request could not get completed * @throws \Exception If the request could not get completed
*/ */
public function head(string $uri, array $options = []): IResponse { public function head(string $uri, array $options = []): IResponse {
$this->preventLocalAddress($uri, $options);
$response = $this->client->request('head', $uri, $this->buildRequestOptions($options)); $response = $this->client->request('head', $uri, $this->buildRequestOptions($options));
return new Response($response); return new Response($response);
} }
@ -238,6 +288,8 @@ class Client implements IClient {
* @throws \Exception If the request could not get completed * @throws \Exception If the request could not get completed
*/ */
public function post(string $uri, array $options = []): IResponse { public function post(string $uri, array $options = []): IResponse {
$this->preventLocalAddress($uri, $options);
if (isset($options['body']) && is_array($options['body'])) { if (isset($options['body']) && is_array($options['body'])) {
$options['form_params'] = $options['body']; $options['form_params'] = $options['body'];
unset($options['body']); unset($options['body']);
@ -276,6 +328,7 @@ class Client implements IClient {
* @throws \Exception If the request could not get completed * @throws \Exception If the request could not get completed
*/ */
public function put(string $uri, array $options = []): IResponse { public function put(string $uri, array $options = []): IResponse {
$this->preventLocalAddress($uri, $options);
$response = $this->client->request('put', $uri, $this->buildRequestOptions($options)); $response = $this->client->request('put', $uri, $this->buildRequestOptions($options));
return new Response($response); return new Response($response);
} }
@ -310,6 +363,7 @@ class Client implements IClient {
* @throws \Exception If the request could not get completed * @throws \Exception If the request could not get completed
*/ */
public function delete(string $uri, array $options = []): IResponse { public function delete(string $uri, array $options = []): IResponse {
$this->preventLocalAddress($uri, $options);
$response = $this->client->request('delete', $uri, $this->buildRequestOptions($options)); $response = $this->client->request('delete', $uri, $this->buildRequestOptions($options));
return new Response($response); return new Response($response);
} }
@ -344,6 +398,7 @@ class Client implements IClient {
* @throws \Exception If the request could not get completed * @throws \Exception If the request could not get completed
*/ */
public function options(string $uri, array $options = []): IResponse { public function options(string $uri, array $options = []): IResponse {
$this->preventLocalAddress($uri, $options);
$response = $this->client->request('options', $uri, $this->buildRequestOptions($options)); $response = $this->client->request('options', $uri, $this->buildRequestOptions($options));
return new Response($response); return new Response($response);
} }

View File

@ -32,6 +32,7 @@ use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService; use OCP\Http\Client\IClientService;
use OCP\ICertificateManager; use OCP\ICertificateManager;
use OCP\IConfig; use OCP\IConfig;
use OCP\ILogger;
/** /**
* Class ClientService * Class ClientService
@ -41,16 +42,16 @@ use OCP\IConfig;
class ClientService implements IClientService { class ClientService implements IClientService {
/** @var IConfig */ /** @var IConfig */
private $config; private $config;
/** @var ILogger */
private $logger;
/** @var ICertificateManager */ /** @var ICertificateManager */
private $certificateManager; private $certificateManager;
/**
* @param IConfig $config
* @param ICertificateManager $certificateManager
*/
public function __construct(IConfig $config, public function __construct(IConfig $config,
ILogger $logger,
ICertificateManager $certificateManager) { ICertificateManager $certificateManager) {
$this->config = $config; $this->config = $config;
$this->logger = $logger;
$this->certificateManager = $certificateManager; $this->certificateManager = $certificateManager;
} }
@ -58,6 +59,6 @@ class ClientService implements IClientService {
* @return Client * @return Client
*/ */
public function newClient(): IClient { public function newClient(): IClient {
return new Client($this->config, $this->certificateManager, new GuzzleClient()); return new Client($this->config, $this->logger, $this->certificateManager, new GuzzleClient());
} }
} }

View File

@ -804,6 +804,7 @@ class Server extends ServerContainer implements IServerContainer {
$uid = $user ? $user : null; $uid = $user ? $user : null;
return new ClientService( return new ClientService(
$c->getConfig(), $c->getConfig(),
$c->getLogger(),
new \OC\Security\CertificateManager( new \OC\Security\CertificateManager(
$uid, $uid,
new View(), new View(),

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2020 Joas Schilling <coding@schilljs.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCP\Http\Client;
/**
* @since 19.0.0
*/
class LocalServerException extends \RuntimeException {
}

View File

@ -11,33 +11,38 @@ namespace Test\Http\Client;
use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Response;
use OC\Http\Client\Client; use OC\Http\Client\Client;
use OC\Security\CertificateManager; use OC\Security\CertificateManager;
use OCP\Http\Client\LocalServerException;
use OCP\ICertificateManager; use OCP\ICertificateManager;
use OCP\IConfig; use OCP\IConfig;
use OCP\ILogger;
use PHPUnit\Framework\MockObject\MockObject;
/** /**
* Class ClientTest * Class ClientTest
*/ */
class ClientTest extends \Test\TestCase { class ClientTest extends \Test\TestCase {
/** @var \GuzzleHttp\Client|\PHPUnit_Framework_MockObject_MockObject */ /** @var \GuzzleHttp\Client|MockObject */
private $guzzleClient; private $guzzleClient;
/** @var CertificateManager|\PHPUnit_Framework_MockObject_MockObject */ /** @var CertificateManager|MockObject */
private $certificateManager; private $certificateManager;
/** @var Client */ /** @var Client */
private $client; private $client;
/** @var IConfig|\PHPUnit_Framework_MockObject_MockObject */ /** @var IConfig|MockObject */
private $config; private $config;
/** @var ILogger|MockObject */
private $logger;
/** @var array */ /** @var array */
private $defaultRequestOptions; private $defaultRequestOptions;
protected function setUp(): void { protected function setUp(): void {
parent::setUp(); parent::setUp();
$this->config = $this->createMock(IConfig::class); $this->config = $this->createMock(IConfig::class);
$this->guzzleClient = $this->getMockBuilder(\GuzzleHttp\Client::class) $this->logger = $this->createMock(ILogger::class);
->disableOriginalConstructor() $this->guzzleClient = $this->createMock(\GuzzleHttp\Client::class);
->getMock();
$this->certificateManager = $this->createMock(ICertificateManager::class); $this->certificateManager = $this->createMock(ICertificateManager::class);
$this->client = new Client( $this->client = new Client(
$this->config, $this->config,
$this->logger,
$this->certificateManager, $this->certificateManager,
$this->guzzleClient $this->guzzleClient
); );
@ -149,19 +154,127 @@ class ClientTest extends \Test\TestCase {
], self::invokePrivate($this->client, 'getProxyUri')); ], self::invokePrivate($this->client, 'getProxyUri'));
} }
public function dataPreventLocalAddress():array {
return [
['localhost/foo.bar'],
['localHost/foo.bar'],
['random-host/foo.bar'],
['[::1]/bla.blub'],
['[::]/bla.blub'],
['192.168.0.1'],
['172.16.42.1'],
['[fdf8:f53b:82e4::53]/secret.ics'],
['[fe80::200:5aee:feaa:20a2]/secret.ics'],
['[0:0:0:0:0:0:10.0.0.1]/secret.ics'],
['[0:0:0:0:0:ffff:127.0.0.0]/secret.ics'],
['10.0.0.1'],
['another-host.local'],
['service.localhost'],
['!@#$'], // test invalid url
];
}
/**
* @dataProvider dataPreventLocalAddress
* @param string $uri
*/
public function testPreventLocalAddress(string $uri): void {
$this->expectException(LocalServerException::class);
self::invokePrivate($this->client, 'preventLocalAddress', ['http://' . $uri, []]);
}
/**
* @dataProvider dataPreventLocalAddress
* @param string $uri
*/
public function testPreventLocalAddressDisabledByGlobalConfig(string $uri): void {
$this->config->expects($this->once())
->method('getSystemValueBool')
->with('allow_local_remote_servers', false)
->willReturn(true);
// $this->expectException(LocalServerException::class);
self::invokePrivate($this->client, 'preventLocalAddress', ['http://' . $uri, []]);
}
/**
* @dataProvider dataPreventLocalAddress
* @param string $uri
*/
public function testPreventLocalAddressDisabledByOption(string $uri): void {
$this->config->expects($this->never())
->method('getSystemValueBool');
// $this->expectException(LocalServerException::class);
self::invokePrivate($this->client, 'preventLocalAddress', ['http://' . $uri, [
'nextcloud' => ['allow_local_address' => true],
]]);
}
/**
* @dataProvider dataPreventLocalAddress
* @param string $uri
*/
public function testPreventLocalAddressOnGet(string $uri): void {
$this->expectException(LocalServerException::class);
$this->client->get('http://' . $uri);
}
/**
* @dataProvider dataPreventLocalAddress
* @param string $uri
*/
public function testPreventLocalAddressOnHead(string $uri): void {
$this->expectException(LocalServerException::class);
$this->client->head('http://' . $uri);
}
/**
* @dataProvider dataPreventLocalAddress
* @param string $uri
*/
public function testPreventLocalAddressOnPost(string $uri): void {
$this->expectException(LocalServerException::class);
$this->client->post('http://' . $uri);
}
/**
* @dataProvider dataPreventLocalAddress
* @param string $uri
*/
public function testPreventLocalAddressOnPut(string $uri): void {
$this->expectException(LocalServerException::class);
$this->client->put('http://' . $uri);
}
/**
* @dataProvider dataPreventLocalAddress
* @param string $uri
*/
public function testPreventLocalAddressOnDelete(string $uri): void {
$this->expectException(LocalServerException::class);
$this->client->delete('http://' . $uri);
}
private function setUpDefaultRequestOptions(): void { private function setUpDefaultRequestOptions(): void {
$this->config->expects($this->once())
->method('getSystemValueBool')
->with('allow_local_remote_servers', false)
->willReturn(true);
$this->config $this->config
->expects($this->at(0)) ->expects($this->at(1))
->method('getSystemValue') ->method('getSystemValue')
->with('proxy', null) ->with('proxy', null)
->willReturn('foo'); ->willReturn('foo');
$this->config $this->config
->expects($this->at(1)) ->expects($this->at(2))
->method('getSystemValue') ->method('getSystemValue')
->with('proxyuserpwd', null) ->with('proxyuserpwd', null)
->willReturn(null); ->willReturn(null);
$this->config $this->config
->expects($this->at(2)) ->expects($this->at(3))
->method('getSystemValue') ->method('getSystemValue')
->with('proxyexclude', []) ->with('proxyexclude', [])
->willReturn([]); ->willReturn([]);