diff --git a/apps/dav/lib/Connector/Sabre/CorsPlugin.php b/apps/dav/lib/Connector/Sabre/CorsPlugin.php new file mode 100644 index 00000000000..718403e356a --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/CorsPlugin.php @@ -0,0 +1,189 @@ + + * + * @copyright Copyright (c) 2018, ownCloud GmbH + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * 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, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\DAV\Connector\Sabre; + +use OCP\IUserSession; +use OCP\Util; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +/** + * Class CorsPlugin is a plugin which adds CORS headers to the responses + */ +class CorsPlugin extends ServerPlugin { + /** + * Reference to main server object + * + * @var \Sabre\DAV\Server + */ + private $server; + + /** + * Reference to logged in user's session + * + * @var IUserSession + */ + private $userSession; + + /** @var array */ + private $extraHeaders; + /** + * @var bool + */ + private $alreadyExecuted = false; + + /** + * @param IUserSession $userSession + */ + public function __construct(IUserSession $userSession) { + $this->userSession = $userSession; + } + + private function getExtraHeaders(RequestInterface $request) { + if ($this->extraHeaders === null) { + if ($this->userSession->getUser() === null) { + $this->extraHeaders['Access-Control-Allow-Methods'] = [ + 'OPTIONS', + 'GET', + 'HEAD', + 'DELETE', + 'PROPFIND', + 'PUT', + 'PROPPATCH', + 'COPY', + 'MOVE', + 'REPORT', + 'SEARCH', + ]; + } else { + $this->extraHeaders['Access-Control-Allow-Methods'] = $this->server->getAllowedMethods($request->getPath()); + } + } + return $this->extraHeaders; + } + + /** + * This initializes the plugin. + * + * This function is called by \Sabre\DAV\Server, after + * addPlugin is called. + * + * This method should set up the required event subscriptions. + * + * @param \Sabre\DAV\Server $server + * @return void + */ + public function initialize(\Sabre\DAV\Server $server) { + $this->server = $server; + + $request = $this->server->httpRequest; + if (!$request->hasHeader('Origin')) { + return; + } + $originHeader = $request->getHeader('Origin'); + if ($this->ignoreOriginHeader($originHeader)) { + return; + } + if (Util::isSameDomain($originHeader, $request->getAbsoluteUrl())) { + return; + } + + $this->server->on('beforeMethod:*', [$this, 'setCorsHeaders']); + $this->server->on('exception', [$this, 'onException']); + $this->server->on('beforeMethod:OPTIONS', [$this, 'setOptionsRequestHeaders'], 5); + } + + public function onException(\Throwable $ex) { + $this->setCorsHeaders($this->server->httpRequest, $this->server->httpResponse); + } + + /** + * This method sets the cors headers for all requests + * + * @param RequestInterface $request + * @param ResponseInterface $response + * @return void + */ + public function setCorsHeaders(RequestInterface $request, ResponseInterface $response) { + if ($request->getHeader('origin') === null) { + return; + } + if ($this->alreadyExecuted) { + return; + } + $this->alreadyExecuted = true; + $requesterDomain = $request->getHeader('origin'); + // unauthenticated request shall add cors headers as well + $userId = null; + if ($this->userSession->getUser() !== null) { + $userId = $this->userSession->getUser()->getUID(); + } + + $headers = \OC_Response::setCorsHeaders($userId, $requesterDomain, null, $this->getExtraHeaders($request)); + foreach ($headers as $key => $value) { + $response->addHeader($key, \implode(',', $value)); + } + } + + /** + * Handles the OPTIONS request + * + * @param RequestInterface $request + * @param ResponseInterface $response + * + * @return false|void + * @throws \InvalidArgumentException + */ + public function setOptionsRequestHeaders(RequestInterface $request, ResponseInterface $response) { + $authorization = $request->getHeader('Authorization'); + if ($authorization === null || $authorization === '') { + // Set the proper response + $response->setStatus(200); + $response = \OC_Response::setOptionsRequestHeaders($response, $this->getExtraHeaders($request)); + + // Since All OPTIONS requests are unauthorized, we will have to return false from here + // If we don't return false, due to no authorization, a 401-Unauthorized will be thrown + // Which we don't want here + // Hence this sendResponse + $this->server->sapi->sendResponse($response); + return false; + } + } + + /** + * in addition to schemas used by extensions we ignore empty origin header + * values as well as 'null' which is not valid by the specification but used + * by some clients. + * @link https://github.com/owncloud/core/pull/32120#issuecomment-407008243 + * + * @param string $originHeader + * @return bool + */ + public function ignoreOriginHeader($originHeader) { + if (\in_array($originHeader, ['', null, 'null'], true)) { + return true; + } + $schema = \parse_url($originHeader, PHP_URL_SCHEME); + return \in_array(\strtolower($schema), ['moz-extension', 'chrome-extension']); + } +} diff --git a/apps/dav/lib/Connector/Sabre/ServerFactory.php b/apps/dav/lib/Connector/Sabre/ServerFactory.php index d0cc8aab5d0..eb6dfe8317e 100644 --- a/apps/dav/lib/Connector/Sabre/ServerFactory.php +++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php @@ -100,6 +100,7 @@ class ServerFactory { // Load plugins $server->addPlugin(new \OCA\DAV\Connector\Sabre\MaintenancePlugin($this->config, $this->l10n)); + $server->addPlugin(new \OCA\DAV\Connector\Sabre\CorsPlugin($this->userSession)); $server->addPlugin(new \OCA\DAV\Connector\Sabre\BlockLegacyClientPlugin($this->config)); $server->addPlugin(new \OCA\DAV\Connector\Sabre\AnonymousOptionsPlugin()); $server->addPlugin($authPlugin); diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index 603e015fca9..84e55d6247c 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -51,6 +51,7 @@ use OCA\DAV\Connector\Sabre\CachingTree; use OCA\DAV\Connector\Sabre\ChecksumUpdatePlugin; use OCA\DAV\Connector\Sabre\CommentPropertiesPlugin; use OCA\DAV\Connector\Sabre\CopyEtagHeaderPlugin; +use OCA\DAV\Connector\Sabre\CorsPlugin; use OCA\DAV\Connector\Sabre\DavAclPlugin; use OCA\DAV\Connector\Sabre\DummyGetResponsePlugin; use OCA\DAV\Connector\Sabre\FakeLockerPlugin; @@ -130,6 +131,8 @@ class Server { $this->server->addPlugin(new ProfilerPlugin($this->request)); $this->server->addPlugin(new BlockLegacyClientPlugin(\OC::$server->getConfig())); $this->server->addPlugin(new AnonymousOptionsPlugin()); + $this->server->addPlugin(new CorsPlugin(\OC::$server->getUserSession())); + $authPlugin = new Plugin(); $authPlugin->addBackend(new PublicAuth()); $this->server->addPlugin($authPlugin); diff --git a/apps/dav/tests/unit/Connector/Sabre/CorsPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/CorsPluginTest.php new file mode 100644 index 00000000000..48daf4fb819 --- /dev/null +++ b/apps/dav/tests/unit/Connector/Sabre/CorsPluginTest.php @@ -0,0 +1,375 @@ + + * + * @copyright Copyright (c) 2018, ownCloud GmbH + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * 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, version 3, + * along with this program. If not, see + * + */ +namespace OCA\DAV\Tests\unit\Connector\Sabre; + +use OCA\DAV\Connector\Sabre\CorsPlugin; +use OCP\IUserSession; +use OCP\IUser; +use OCP\IConfig; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Test\TestCase; + +class CorsPluginTest extends TestCase { + /** + * @var Server + */ + private $server; + + /** + * @var CorsPlugin + */ + private $plugin; + + /** + * @var IUserSession | \PHPUnit\Framework\MockObject\MockObject + */ + private $userSession; + + /** + * @var IConfig | \PHPUnit\Framework\MockObject\MockObject + */ + private $config; + + public function setUp(): void { + parent::setUp(); + $this->server = new Server(); + + $this->server->sapi = $this->getMockBuilder(\stdClass::class) + ->setMethods(['sendResponse']) + ->getMock(); + + $this->server->httpRequest->setMethod('OPTIONS'); + + $this->userSession = $this->createMock(IUserSession::class); + + $this->config = $this->createMock(IConfig::class); + $this->overwriteService('AllConfig', $this->config); + + $this->plugin = new CorsPlugin($this->userSession); + + /** @var ServerPlugin | \PHPUnit\Framework\MockObject\MockObject $extraMethodPlugin */ + $extraMethodPlugin = $this->createMock(ServerPlugin::class); + $extraMethodPlugin->method('getHTTPMethods') + ->with('owncloud/remote.php/dav/files/user1/target/path') + ->willReturn(['EXTRA']); + $extraMethodPlugin->method('getFeatures')->willReturn([]); + + $this->server->addPlugin($extraMethodPlugin); + } + + public function tearDown(): void { + $this->restoreService('AllConfig'); + } + + public function optionsCases() { + $allowedDomains = '["https://requesterdomain.tld", "http://anotherdomain.tld"]'; + + $allowedHeaders = [ + 'OC-Checksum', 'OC-Total-Length', 'OCS-APIREQUEST', 'X-OC-Mtime', + 'OC-RequestAppPassword', 'Accept', + 'Authorization', 'Brief', 'Content-Length', 'Content-Range', + 'Content-Type', 'Date', 'Depth', 'Destination', 'Host', 'If', 'If-Match', + 'If-Modified-Since', 'If-None-Match', 'If-Range', 'If-Unmodified-Since', + 'Location', 'Lock-Token', 'Overwrite', 'Prefer', 'Range', 'Schedule-Reply', + 'Timeout', 'User-Agent', 'X-Expected-Entity-Length', 'Accept-Language', + 'Access-Control-Request-Method', 'Access-Control-Allow-Origin', 'Cache-Control', 'ETag', + 'OC-Autorename', 'OC-CalDav-Import', 'OC-Chunked', 'OC-Etag', 'OC-FileId', + 'OC-LazyOps', 'OC-Total-File-Length', 'Origin', 'X-Request-ID', 'X-Requested-With' + ]; + $allowedMethods = [ + 'GET', + 'OPTIONS', + 'POST', + 'PUT', + 'DELETE', + 'MKCOL', + 'PROPFIND', + 'PATCH', + 'PROPPATCH', + 'REPORT', + 'HEAD', + 'COPY', + 'MOVE', + 'EXTRA', + ]; + $allowedMethodsUnAuthenticated = [ + 'GET', + 'OPTIONS', + 'POST', + 'PUT', + 'DELETE', + 'MKCOL', + 'PROPFIND', + 'PATCH', + 'PROPPATCH', + 'REPORT', + 'HEAD', + 'COPY', + 'MOVE', + ]; + + return [ + 'OPTIONS headers' => + [ + $allowedDomains, + false, + [ + 'Origin' => 'https://requesterdomain.tld', + ], + 200, + [ + 'Access-Control-Allow-Headers' => \implode(',', $allowedHeaders), + 'Access-Control-Allow-Origin' => '*', + 'Access-Control-Allow-Methods' => \implode(',', $allowedMethodsUnAuthenticated), + ], + false + ], + 'OPTIONS headers with user' => + [ + $allowedDomains, + true, + [ + 'Origin' => 'https://requesterdomain.tld', + 'Authorization' => 'abc', + ], + 200, + [ + 'Access-Control-Allow-Headers' => \implode(',', $allowedHeaders), + 'Access-Control-Allow-Origin' => 'https://requesterdomain.tld', + 'Access-Control-Allow-Methods' => \implode(',', $allowedMethods), + ], + true + ], + 'OPTIONS headers no user' => + [ + $allowedDomains, + false, + [ + 'Origin' => 'https://requesterdomain.tld', + 'Authorization' => 'abc', + ], + 200, + [ + 'Access-Control-Allow-Headers' => null, + 'Access-Control-Allow-Origin' => null, + 'Access-Control-Allow-Methods' => null, + ], + true + ], + 'OPTIONS headers domain not allowed' => + [ + '[]', + true, + [ + 'Origin' => 'https://requesterdomain.tld', + 'Authorization' => 'abc', + ], + 200, + [ + 'Access-Control-Allow-Headers' => null, + 'Access-Control-Allow-Origin' => null, + 'Access-Control-Allow-Methods' => null, + ], + true + ], + 'OPTIONS headers not allowed but no cross-domain' => + [ + '[]', + true, + [ + 'Origin' => 'https://requesterdomain.tld', + 'Authorization' => 'abc', + ], + 200, + [ + 'Access-Control-Allow-Headers' => null, + 'Access-Control-Allow-Origin' => null, + 'Access-Control-Allow-Methods' => null, + ], + true + ], + 'OPTIONS headers allowed but no cross-domain' => + [ + '["currentdomain.tld:8080"]', + true, + [ + 'Origin' => 'https://currentdomain.tld:8080', + 'Authorization' => 'abc', + ], + 200, + [ + 'Access-Control-Allow-Headers' => null, + 'Access-Control-Allow-Origin' => null, + 'Access-Control-Allow-Methods' => null, + ], + true + ], + 'OPTIONS headers allowed, cross-domain through different port' => + [ + '["https://currentdomain.tld:8443"]', + true, + [ + 'Origin' => 'https://currentdomain.tld:8443', + 'Authorization' => 'abc', + ], + 200, + [ + 'Access-Control-Allow-Headers' => \implode(',', $allowedHeaders), + 'Access-Control-Allow-Origin' => 'https://currentdomain.tld:8443', + 'Access-Control-Allow-Methods' => \implode(',', $allowedMethods), + ], + true + ], + 'no Origin header' => + [ + $allowedDomains, + true, + [ + ], + 200, + [ + 'Access-Control-Allow-Headers' => null, + 'Access-Control-Allow-Origin' => null, + 'Access-Control-Allow-Methods' => null, + ], + true + ], + ]; + } + + /** + * @dataProvider optionsCases + * @param $allowedDomains + * @param $hasUser + * @param $requestHeaders + * @param $expectedStatus + * @param array $expectedHeaders + * @param bool $expectDavHeaders + */ + public function testOptionsHeaders($allowedDomains, $hasUser, $requestHeaders, $expectedStatus, array $expectedHeaders, $expectDavHeaders = false) { + $this->server->sapi->expects($this->once())->method('sendResponse')->with($this->server->httpResponse); + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('someuser'); + + if ($hasUser) { + $this->userSession->method('getUser')->willReturn($user); + } else { + $this->userSession->method('getUser')->willReturn(null); + } + + $this->config->method('getSystemValue')->willReturn([]); + $this->config->method('getUserValue') + ->with('someuser', 'core', 'domains') + ->willReturn($allowedDomains); + + $this->server->httpRequest->setHeaders($requestHeaders); + $this->server->httpRequest->setAbsoluteUrl('https://currentdomain.tld:8080/owncloud/remote.php/dav/files/user1/target/path'); + $this->server->httpRequest->setUrl('/owncloud/remote.php/dav/files/user1/target/path'); + + $this->server->addPlugin($this->plugin); + $this->server->start(); + + $this->assertEquals($expectedStatus, $this->server->httpResponse->getStatus()); + + foreach ($expectedHeaders as $headerKey => $headerValue) { + if ($headerValue !== null) { + $this->assertTrue($this->server->httpResponse->hasHeader($headerKey), "Response header \"$headerKey\" exists"); + } else { + $this->assertFalse($this->server->httpResponse->hasHeader($headerKey), "Response header \"$headerKey\" does not exist"); + } + $this->assertEquals($headerValue, $this->server->httpResponse->getHeader($headerKey)); + } + + // if it has DAV headers, it means we did not bypass further processing + $this->assertEquals($expectDavHeaders, $this->server->httpResponse->hasHeader('DAV')); + } + + /** + * @dataProvider providesOriginUrls + * @param $expectedValue + * @param $url + */ + public function testExtensionRequests($expectedValue, $url) { + $plugin = new CorsPlugin($this->createMock(IUserSession::class)); + self::assertEquals($expectedValue, $plugin->ignoreOriginHeader($url)); + } + + public function providesOriginUrls() { + return [ + 'Firefox extension' => [true, 'moz-extension://mgmnhfbjphngabcpbpmapnnaabhnchmi/'], + 'Chrome extension' => [true, 'chrome-extension://mgmnhfbjphngabcpbpmapnnaabhnchmi/'], + 'Empty Origin' => [true, ''], + 'Null string Origin' => [true, 'null'], + 'Null Origin' => [true, null], + 'plain http' => [false, 'http://example.net/'], + ]; + } + + public function testAuthenticatedAdditionalAllowedHeaders() { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('someuser'); + + $this->userSession->method('getUser')->willReturn($user); + $this->server->httpRequest->setHeader('Origin', 'https://requesterdomain.tld'); + $this->server->httpRequest->setUrl('/owncloud/remote.php/dav/files/user1/target/path'); + + $this->config->method('getSystemValue')->withConsecutive( + ['cors.allowed-domains', []], + ['cors.allowed-headers', []] + ) + ->willReturnMap([ + ['cors.allowed-domains', [], []], + ['cors.allowed-headers', [], ['X-Additional-Configured-Header', 'authorization']] + ]); + $this->config->method('getUserValue')->willReturn('["https://requesterdomain.tld"]'); + + $this->server->addPlugin($this->plugin); + + $this->plugin->setCorsHeaders($this->server->httpRequest, $this->server->httpResponse); + self::assertEquals( + 'X-Additional-Configured-Header,authorization,OC-Checksum,OC-Total-Length,OCS-APIREQUEST,X-OC-Mtime,OC-RequestAppPassword,Accept,Authorization,Brief,Content-Length,Content-Range,Content-Type,Date,Depth,Destination,Host,If,If-Match,If-Modified-Since,If-None-Match,If-Range,If-Unmodified-Since,Location,Lock-Token,Overwrite,Prefer,Range,Schedule-Reply,Timeout,User-Agent,X-Expected-Entity-Length,Accept-Language,Access-Control-Request-Method,Access-Control-Allow-Origin,Cache-Control,ETag,OC-Autorename,OC-CalDav-Import,OC-Chunked,OC-Etag,OC-FileId,OC-LazyOps,OC-Total-File-Length,Origin,X-Request-ID,X-Requested-With', + $this->server->httpResponse->getHeader('Access-Control-Allow-Headers') + ); + } + + public function testUnauthenticatedAdditionalAllowedHeaders() { + $this->userSession->method('getUser')->willReturn(null); + $this->server->httpRequest->setHeader('Origin', 'https://requesterdomain.tld'); + + $this->config->method('getSystemValue')->withConsecutive( + ['cors.allowed-domains', []], + ['cors.allowed-headers', []] + ) + ->willReturnMap([ + ['cors.allowed-domains', [], ['https://requesterdomain.tld']], + ['cors.allowed-headers', [], ['X-Additional-Configured-Header', 'authorization']] + ]); + + $this->server->addPlugin($this->plugin); + + $this->plugin->setCorsHeaders($this->server->httpRequest, $this->server->httpResponse); + self::assertEquals( + 'X-Additional-Configured-Header,authorization,OC-Checksum,OC-Total-Length,OCS-APIREQUEST,X-OC-Mtime,OC-RequestAppPassword,Accept,Authorization,Brief,Content-Length,Content-Range,Content-Type,Date,Depth,Destination,Host,If,If-Match,If-Modified-Since,If-None-Match,If-Range,If-Unmodified-Since,Location,Lock-Token,Overwrite,Prefer,Range,Schedule-Reply,Timeout,User-Agent,X-Expected-Entity-Length,Accept-Language,Access-Control-Request-Method,Access-Control-Allow-Origin,Cache-Control,ETag,OC-Autorename,OC-CalDav-Import,OC-Chunked,OC-Etag,OC-FileId,OC-LazyOps,OC-Total-File-Length,Origin,X-Request-ID,X-Requested-With', + $this->server->httpResponse->getHeader('Access-Control-Allow-Headers') + ); + } +} diff --git a/lib/private/AppFramework/DependencyInjection/DIContainer.php b/lib/private/AppFramework/DependencyInjection/DIContainer.php index a012d1e8ea6..bc20037e81d 100644 --- a/lib/private/AppFramework/DependencyInjection/DIContainer.php +++ b/lib/private/AppFramework/DependencyInjection/DIContainer.php @@ -234,7 +234,8 @@ class DIContainer extends SimpleContainer implements IAppContainer { $c->get(IRequest::class), $c->get(IControllerMethodReflector::class), $c->get(IUserSession::class), - $c->get(IThrottler::class) + $c->get(IThrottler::class), + $c->get(IConfig::class), ) ); $dispatcher->registerMiddleware( diff --git a/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php index 8bdacf550b6..c3f1a105400 100644 --- a/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php +++ b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php @@ -29,7 +29,6 @@ namespace OC\AppFramework\Middleware\Security; use OC\AppFramework\Middleware\Security\Exceptions\SecurityException; use OC\AppFramework\Utility\ControllerMethodReflector; use OC\Authentication\Exceptions\PasswordLoginForbiddenException; -use OC\User\Session; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\CORS; @@ -37,7 +36,9 @@ use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\Response; use OCP\AppFramework\Middleware; +use OCP\IConfig; use OCP\IRequest; +use OCP\IUserSession; use OCP\Security\Bruteforce\IThrottler; use ReflectionMethod; @@ -48,23 +49,12 @@ use ReflectionMethod; * https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS */ class CORSMiddleware extends Middleware { - /** @var IRequest */ - private $request; - /** @var ControllerMethodReflector */ - private $reflector; - /** @var Session */ - private $session; - /** @var IThrottler */ - private $throttler; - - public function __construct(IRequest $request, - ControllerMethodReflector $reflector, - Session $session, - IThrottler $throttler) { - $this->request = $request; - $this->reflector = $reflector; - $this->session = $session; - $this->throttler = $throttler; + public function __construct(private IRequest $request, + private ControllerMethodReflector $reflector, + private IUserSession $session, + private IThrottler $throttler, + private IConfig $config, + ) { } /** @@ -135,24 +125,25 @@ class CORSMiddleware extends Middleware { * @throws SecurityException */ public function afterController($controller, $methodName, Response $response) { + $userId = !is_null($this->session->getUser()) ? $this->session->getUser()->getUID() : null; + // only react if it's a CORS request and if the request sends origin and + $reflectionMethod = new ReflectionMethod($controller, $methodName); + if ($this->request->getHeader("Origin") !== null + && $this->hasAnnotationOrAttribute($reflectionMethod, 'CORS', CORS::class) + && !is_null($userId)) { + $requesterDomain = $this->request->getHeader("Origin"); + \OC_Response::setCorsHeaders($userId, $requesterDomain, $this->config); - if (isset($this->request->server['HTTP_ORIGIN'])) { - $reflectionMethod = new ReflectionMethod($controller, $methodName); - if ($this->hasAnnotationOrAttribute($reflectionMethod, 'CORS', CORS::class)) { - // allow credentials headers must not be true or CSRF is possible - // otherwise - foreach ($response->getHeaders() as $header => $value) { - if (strtolower($header) === 'access-control-allow-credentials' && - strtolower(trim($value)) === 'true') { - $msg = 'Access-Control-Allow-Credentials must not be '. - 'set to true in order to prevent CSRF'; - throw new SecurityException($msg); - } + // allow credentials headers must not be true or CSRF is possible + // otherwise + foreach ($response->getHeaders() as $header => $value) { + if (strtolower($header) === 'access-control-allow-credentials' && + strtolower(trim($value)) === 'true') { + $msg = 'Access-Control-Allow-Credentials must not be '. + 'set to true in order to prevent CSRF'; + throw new SecurityException($msg); } - - $origin = $this->request->server['HTTP_ORIGIN']; - $response->addHeader('Access-Control-Allow-Origin', $origin); } } return $response; diff --git a/lib/private/Route/Router.php b/lib/private/Route/Router.php index fe97623176d..ac08a56dfff 100644 --- a/lib/private/Route/Router.php +++ b/lib/private/Route/Router.php @@ -274,6 +274,41 @@ class Router implements IRouter { } $matcher = new UrlMatcher($this->root, $this->context); + + if (\OC::$server->getRequest()->getMethod() === "OPTIONS") { + try { + // Checking whether the actual request (one which OPTIONS is pre-flight for) + // Is actually valid + $requestingMethod = \OC::$server->getRequest()->getHeader('Access-Control-Request-Method'); + $tempContext = $this->context; + $tempContext->setMethod($requestingMethod); + $tempMatcher = new UrlMatcher($this->root, $tempContext); + $parameters = $tempMatcher->match($url); + + // Reach here if it's valid + $response = new \OC\OCS\Result(null, 100, 'OPTIONS request successful'); + $response = \OC_Response::setOptionsRequestHeaders($response); + \OC_API::respond($response, \OC_API::requestedFormat()); + + // Return since no more processing for an OPTIONS request is required + return; + } catch (ResourceNotFoundException $e) { + if (substr($url, -1) !== '/') { + // We allow links to apps/files? for backwards compatibility reasons + // However, since Symfony does not allow empty route names, the route + // we need to match is '/', so we need to append the '/' here. + try { + $parameters = $matcher->match($url . '/'); + } catch (ResourceNotFoundException $newException) { + // If we still didn't match a route, we throw the original exception + throw $e; + } + } else { + throw $e; + } + } + } + try { $parameters = $matcher->match($url); } catch (ResourceNotFoundException $e) { diff --git a/lib/private/legacy/OC_Response.php b/lib/private/legacy/OC_Response.php index 9440feae3cd..a4646799c62 100644 --- a/lib/private/legacy/OC_Response.php +++ b/lib/private/legacy/OC_Response.php @@ -103,4 +103,200 @@ class OC_Response { header('X-XSS-Protection: 1; mode=block'); // Enforce browser based XSS filters } } + + /** + * This function adds the CORS headers if the requester domain is white-listed + * + * @param string $userId + * @param string $domain + * @param \OCP\IConfig $config + * @param array $headers + * + * Format of $headers: + * Array [ + * "Access-Control-Allow-Headers": ["a", "b", "c"], + * "Access-Control-Allow-Origin": ["a", "b", "c"], + * "Access-Control-Allow-Methods": ["a", "b", "c"] + * ] + * + * @return array + */ + public static function setCorsHeaders($userId, $domain, \OCP\IConfig $config = null, array $headers = []) { + if ($config === null) { + $config = \OC::$server->getConfig(); + } + // first check if any of the global CORS domains matches + $globalAllowedDomains = $config->getSystemValue('cors.allowed-domains', []); + $isCorsRequest = (\is_array($globalAllowedDomains) && \in_array($domain, $globalAllowedDomains, true)); + if (!$isCorsRequest && $userId !== null) { + // check if any of the user specific CORS domains matches + $allowedDomains = \json_decode($config->getUserValue($userId, 'core', 'domains'), true); + $isCorsRequest = (\is_array($allowedDomains) && \in_array($domain, $allowedDomains, true)); + } + if ($isCorsRequest) { + // TODO: infer allowed verbs from existing known routes + $allHeaders['Access-Control-Allow-Headers'] = self::getAllowedCorsHeaders($config); + $allHeaders['Access-Control-Expose-Headers'] = self::getExposeCorsHeaders(); + $allHeaders['Access-Control-Allow-Origin'] = [$domain]; + $allHeaders['Access-Control-Allow-Methods'] = ['GET', 'OPTIONS', 'POST', 'PUT', 'DELETE', 'MKCOL', 'PROPFIND', 'PATCH', 'PROPPATCH', 'REPORT']; + + foreach ($headers as $key => $value) { + if (\array_key_exists($key, $allHeaders)) { + $allHeaders[$key] = \array_unique(\array_merge($allHeaders[$key], $value)); + } + } + + return $allHeaders; + } + return []; + } + + /** + * This function adds the CORS headers for all domains + * + * @param Sabre\HTTP\ResponseInterface $response + * @param array $headers + * + * Format of $headers: + * Array [ + * "Access-Control-Allow-Headers": ["a", "b", "c"], + * "Access-Control-Allow-Origin": ["a", "b", "c"], + * "Access-Control-Allow-Methods": ["a", "b", "c"] + * ] + * + * @param \OCP\IConfig|null $config + * @return Sabre\HTTP\ResponseInterface $response + */ + public static function setOptionsRequestHeaders($response, $headers = [], \OCP\IConfig $config = null) { + // TODO: infer allowed verbs from existing known routes + $allHeaders['Access-Control-Allow-Headers'] = self::getAllowedCorsHeaders($config); + $allHeaders['Access-Control-Allow-Origin'] = ['*']; + $allHeaders['Access-Control-Allow-Methods'] = self::getAllowedCorsMethods(); + + foreach ($headers as $key => $value) { + if (\array_key_exists($key, $allHeaders)) { + $allHeaders[$key] = \array_unique(\array_merge($allHeaders[$key], $value)); + } + } + + foreach ($allHeaders as $key => $value) { + $response->addHeader($key, \implode(',', $value)); + } + + return $response; + } + + /** + * This are the allowed methods a browser can use from javascript code. + * + * @return string[] + */ + private static function getAllowedCorsMethods() { + return [ + 'GET', + 'OPTIONS', + 'POST', + 'PUT', + 'DELETE', + 'MKCOL', + 'PROPFIND', + 'PATCH', + 'PROPPATCH', + 'REPORT', + 'SEARCH' + ]; + } + + /** + * These are the header which a browser can access from javascript code. + * Simple headers are always accessible. + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers + * + * @return array + */ + private static function getExposeCorsHeaders() { + return [ + 'Content-Location', + 'DAV', + 'ETag', + 'Link', + 'Lock-Token', + 'OC-ETag', + 'OC-Checksum', + 'OC-FileId', + 'OC-JobStatus-Location', + 'OC-RequestAppPassword', + 'Vary', + 'Webdav-Location', + 'X-Sabre-Status', + ]; + } + + /** + * These are the headers the browser is allowed to ask for in a CORS request. + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers + * + * @param \OCP\IConfig $config + * @return array|mixed + */ + private static function getAllowedCorsHeaders(\OCP\IConfig $config = null) { + if ($config === null) { + $config = \OC::$server->getConfig(); + } + $allowedDefaultHeaders = [ + // own headers + 'OC-Checksum', + 'OC-Total-Length', + 'OCS-APIREQUEST', + 'X-OC-Mtime', + 'OC-RequestAppPassword', + // as used in sabre + 'Accept', + 'Authorization', + 'Brief', + 'Content-Length', + 'Content-Range', + 'Content-Type', + 'Date', + 'Depth', + 'Destination', + 'Host', + 'If', + 'If-Match', + 'If-Modified-Since', + 'If-None-Match', + 'If-Range', + 'If-Unmodified-Since', + 'Location', + 'Lock-Token', + 'Overwrite', + 'Prefer', + 'Range', + 'Schedule-Reply', + 'Timeout', + 'User-Agent', + 'X-Expected-Entity-Length', + // generally used headers in core + 'Accept-Language', + 'Access-Control-Request-Method', + 'Access-Control-Allow-Origin', + 'Cache-Control', + 'ETag', + 'OC-Autorename', + 'OC-CalDav-Import', + 'OC-Chunked', + 'OC-Etag', + 'OC-FileId', + 'OC-LazyOps', + 'OC-Total-File-Length', + 'OC-Total-Length', + 'Origin', + 'X-Request-ID', + 'X-Requested-With' + ]; + $corsAllowedHeaders = $config->getSystemValue('cors.allowed-headers', []); + $corsAllowedHeaders = \array_merge($corsAllowedHeaders, $allowedDefaultHeaders); + $corsAllowedHeaders = \array_unique(\array_values($corsAllowedHeaders)); + return $corsAllowedHeaders; + } } diff --git a/lib/public/Util.php b/lib/public/Util.php index 781aa87d66b..8811db185dd 100644 --- a/lib/public/Util.php +++ b/lib/public/Util.php @@ -624,4 +624,59 @@ class Util { } return true; } + + /** + * Returns the protocol, domain and port as string in a normalized + * format for easier comparison. + * Example: "HTTPS://HOST.tld:8080" is returned as "https://host.tld:8080" + * + * If no port was specified, it will add the default port + * of the specified protocol (80 for http or 443 for https) + * + * @param string $url full url + * @return string protocol, domain and port as string + * @since 28.0.0 + */ + public static function getFullDomain(string $url): string { + $parts = \parse_url($url); + if ($parts === false) { + throw new \InvalidArgumentException('Invalid url "' . $url . '"'); + } + if (!isset($parts['scheme']) || !isset($parts['host'])) { + throw new \InvalidArgumentException('Invalid url "' . $url . '"'); + } + $protocol = \strtolower($parts['scheme']); + $host = \strtolower($parts['host']); + $port = null; + if ($protocol === 'http') { + $port = 80; + } elseif ($protocol === 'https') { + $port = 443; + } else { + throw new \InvalidArgumentException('Only http based URLs supported'); + } + + if (isset($parts['port']) && $port !== '') { + $port = $parts['port']; + } + + return $protocol . '://' . \strtolower($host) . ':' . $port; + } + + /** + * Check whether the given URLs have the same protocol, domain and port. + * This is useful to check a browser's cross-domain situation. + * If this method returns false, a browser would consider both URLs to be + * a cross-domain situation and would require a CORS setup. + * + * @param string $url1 + * @param string $url2 + * + * @return bool true if both URLs have the same protocol, domain and port + * + * @since 28.0.0 + */ + public static function isSameDomain(string $url1, string $url2): bool { + return self::getFullDomain($url1) === self::getFullDomain($url2); + } } diff --git a/settings/Controller/CorsController.php b/settings/Controller/CorsController.php new file mode 100644 index 00000000000..cd3e82643a7 --- /dev/null +++ b/settings/Controller/CorsController.php @@ -0,0 +1,156 @@ + + */ + +namespace OC\Settings\Controller; + +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Http\JSONResponse; +use OCP\ILogger; +use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\IConfig; +use OCP\IUserSession; + +/** + * This controller is responsible for managing white-listed domains for CORS + * + * @package OC\Settings\Controller + */ +class CorsController extends Controller { + /** @var ILogger */ + private $logger; + + /** @var IURLGenerator */ + private $urlGenerator; + + /** @var string */ + private $userId; + + /** @var IConfig */ + private $config; + + /** + * CorsController constructor. + * + * @param string $AppName The app's name. + * @param IRequest $request The request. + * @param IUserSession $userSession Logged in user's session + * @param ILogger $logger The logger. + * @param IURLGenerator $urlGenerator Use for url generation + * @param IConfig $config + */ + public function __construct($AppName, IRequest $request, + IUserSession $userSession, + ILogger $logger, + IURLGenerator $urlGenerator, + IConfig $config) { + parent::__construct($AppName, $request); + + $this->AppName = $AppName; + $this->config = $config; + $this->userId = $userSession->getUser()->getUID(); + $this->logger = $logger; + $this->urlGenerator = $urlGenerator; + } + + /** + * Returns a redirect response + * @return RedirectResponse + */ + private function getRedirectResponse() { + return new RedirectResponse( + $this->urlGenerator->linkToRouteAbsolute( + 'settings.SettingsPage.getPersonal', + ['sectionid' => 'security'] + ) . '#cors' + ); + } + + /** + * Gets all White-listed domains + * + * @return JSONResponse All the White-listed domains + */ + public function getDomains() { + $userId = $this->userId; + + if (empty($this->config->getUserValue($userId, 'core', 'domains'))) { + $domains = []; + } else { + $domains = json_decode($this->config->getUserValue($userId, 'core', 'domains')); + } + + return new JSONResponse($domains); + } + + /** + * WhiteLists a domain for CORS + * + * @param string $domain The domain to whitelist + * @return RedirectResponse Redirection to the settings page. + */ + public function addDomain($domain) { + if (!isset($domain) || !self::isValidUrl($domain)) { + return $this->getRedirectResponse(); + } + + $userId = $this->userId; + $domains = json_decode($this->config->getUserValue($userId, 'core', 'domains')); + $domains = array_filter($domains); + array_push($domains, $domain); + + // In case same domain is added + $domains = array_unique($domains); + + // Store as comma seperated string + $domainsString = json_encode($domains); + + $this->config->setUserValue($userId, 'core', 'domains', $domainsString); + $this->logger->debug("The domain {$domain} has been white-listed.", ['app' => $this->appName]); + + return $this->getRedirectResponse(); + } + + /** + * Removes a WhiteListed Domain + * + * @param string $domain Domain to remove + * @return RedirectResponse Redirection to the settings page. + */ + public function removeDomain($id) { + $userId = $this->userId; + $domains = json_decode($this->config->getUserValue($userId, 'core', 'domains')); + + if ($id >= 0 && $id < count($domains)) { + unset($domains[$id]); + $this->config->setUserValue($userId, 'core', 'domains', json_encode($domains)); + } + + return $this->getRedirectResponse(); + } + + /** + * Checks whether a URL is valid + * @param string $url URL to check + * @return boolean whether URL is valid + */ + private static function isValidUrl($url) { + return (filter_var($url, FILTER_VALIDATE_URL) !== false); + } +} diff --git a/tests/lib/AppFramework/Middleware/Security/CORSMiddlewareTest.php b/tests/lib/AppFramework/Middleware/Security/CORSMiddlewareTest.php index 80c2ed84451..30d4eeda79e 100644 --- a/tests/lib/AppFramework/Middleware/Security/CORSMiddlewareTest.php +++ b/tests/lib/AppFramework/Middleware/Security/CORSMiddlewareTest.php @@ -15,12 +15,12 @@ use OC\AppFramework\Http\Request; use OC\AppFramework\Middleware\Security\CORSMiddleware; use OC\AppFramework\Middleware\Security\Exceptions\SecurityException; use OC\AppFramework\Utility\ControllerMethodReflector; -use OC\User\Session; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\Response; use OCP\IConfig; use OCP\IRequest; use OCP\IRequestId; +use OCP\IUserSession; use OCP\Security\Bruteforce\IThrottler; use PHPUnit\Framework\MockObject\MockObject; use Test\AppFramework\Middleware\Security\Mock\CORSMiddlewareController; @@ -28,17 +28,25 @@ use Test\AppFramework\Middleware\Security\Mock\CORSMiddlewareController; class CORSMiddlewareTest extends \Test\TestCase { /** @var ControllerMethodReflector */ private $reflector; - /** @var Session|MockObject */ + /** @var IUserSession|MockObject */ private $session; /** @var IThrottler|MockObject */ private $throttler; + /** @var IConfig|MockObject */ + private $config; /** @var CORSMiddlewareController */ private $controller; protected function setUp(): void { parent::setUp(); + + /** @var MockObject */ + $this->config = $this->createMock(IConfig::class); + $this->config->method('getUserValue')->willReturn('["http:\/\/www.test.com"]'); + $this->config->method('setUserValue')->willReturn(true); + $this->reflector = new ControllerMethodReflector(); - $this->session = $this->createMock(Session::class); + $this->session = $this->createMock(IUserSession::class); $this->throttler = $this->createMock(IThrottler::class); $this->controller = new CORSMiddlewareController( 'test', @@ -51,6 +59,17 @@ class CORSMiddlewareTest extends \Test\TestCase { ['testSetCORSAPIHeader'], ['testSetCORSAPIHeaderAttribute'], ]; + + $this->session = $this->getMockBuilder('\OC\User\Session') + ->disableOriginalConstructor() + ->getMock(); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('user'); + $userSession = $this->createMock(IUserSession::class); + $userSession->method('getUser')->willReturn($user); + + $this->session = $userSession; } /** @@ -60,18 +79,24 @@ class CORSMiddlewareTest extends \Test\TestCase { $request = new Request( [ 'server' => [ - 'HTTP_ORIGIN' => 'test' + 'HTTP_ORIGIN' => 'http://www.test.com' ] ], $this->createMock(IRequestId::class), $this->createMock(IConfig::class) ); $this->reflector->reflect($this->controller, $method); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler); + $middleware = new CORSMiddleware( + $request, + $this->reflector, + $this->session, + $this->throttler, + $this->config + ); $response = $middleware->afterController($this->controller, $method, new Response()); $headers = $response->getHeaders(); - $this->assertEquals('test', $headers['Access-Control-Allow-Origin']); + $this->assertEquals('http://www.test.com', $headers['Access-Control-Allow-Origin']); } public function testNoAnnotationNoCORSHEADER(): void { @@ -84,7 +109,7 @@ class CORSMiddlewareTest extends \Test\TestCase { $this->createMock(IRequestId::class), $this->createMock(IConfig::class) ); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->config); $response = $middleware->afterController($this->controller, __FUNCTION__, new Response()); $headers = $response->getHeaders(); @@ -108,7 +133,7 @@ class CORSMiddlewareTest extends \Test\TestCase { $this->createMock(IConfig::class) ); $this->reflector->reflect($this->controller, $method); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->config); $response = $middleware->afterController($this->controller, $method, new Response()); $headers = $response->getHeaders(); @@ -131,14 +156,14 @@ class CORSMiddlewareTest extends \Test\TestCase { $request = new Request( [ 'server' => [ - 'HTTP_ORIGIN' => 'test' + 'HTTP_ORIGIN' => 'http://www.test.com', ] ], $this->createMock(IRequestId::class), $this->createMock(IConfig::class) ); $this->reflector->reflect($this->controller, $method); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->config); $response = new Response(); $response->addHeader('AcCess-control-Allow-Credentials ', 'TRUE'); @@ -164,7 +189,7 @@ class CORSMiddlewareTest extends \Test\TestCase { $this->createMock(IConfig::class) ); $this->reflector->reflect($this->controller, $method); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->config); $this->session->expects($this->once()) ->method('isLoggedIn') ->willReturn(false); @@ -198,7 +223,7 @@ class CORSMiddlewareTest extends \Test\TestCase { $this->createMock(IConfig::class) ); $this->reflector->reflect($this->controller, $method); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->config); $this->session->expects($this->once()) ->method('isLoggedIn') ->willReturn(true); @@ -239,7 +264,7 @@ class CORSMiddlewareTest extends \Test\TestCase { ->with($this->equalTo('user'), $this->equalTo('pass')) ->willReturn(true); $this->reflector->reflect($this->controller, $method); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->config); $middleware->beforeController($this->controller, $method); } @@ -272,7 +297,7 @@ class CORSMiddlewareTest extends \Test\TestCase { ->with($this->equalTo('user'), $this->equalTo('pass')) ->will($this->throwException(new \OC\Authentication\Exceptions\PasswordLoginForbiddenException)); $this->reflector->reflect($this->controller, $method); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->config); $middleware->beforeController($this->controller, $method); } @@ -305,7 +330,7 @@ class CORSMiddlewareTest extends \Test\TestCase { ->with($this->equalTo('user'), $this->equalTo('pass')) ->willReturn(false); $this->reflector->reflect($this->controller, $method); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->config); $middleware->beforeController($this->controller, $method); } @@ -319,7 +344,7 @@ class CORSMiddlewareTest extends \Test\TestCase { $this->createMock(IRequestId::class), $this->createMock(IConfig::class) ); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->config); $response = $middleware->afterException($this->controller, __FUNCTION__, new SecurityException('A security exception')); $expected = new JSONResponse(['message' => 'A security exception'], 500); @@ -335,7 +360,7 @@ class CORSMiddlewareTest extends \Test\TestCase { $this->createMock(IRequestId::class), $this->createMock(IConfig::class) ); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->config); $response = $middleware->afterException($this->controller, __FUNCTION__, new SecurityException('A security exception', 501)); $expected = new JSONResponse(['message' => 'A security exception'], 501); @@ -354,7 +379,7 @@ class CORSMiddlewareTest extends \Test\TestCase { $this->createMock(IRequestId::class), $this->createMock(IConfig::class) ); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->config); $middleware->afterException($this->controller, __FUNCTION__, new \Exception('A regular exception')); } }