From c1fa6401a3abf74cf960d7e1b40f490bb81e1bd1 Mon Sep 17 00:00:00 2001 From: noveens Date: Fri, 21 Jul 2017 15:34:06 +0530 Subject: [PATCH] Enabled CORS on webdav and ocs * Exclude DAV CORS handling when no Origin specified This will exclude non-browser clients from CORS handling. Fixes some clients like davfs which break when CORS is enabled. * fix: CORS on WebDAV is not working WebDAV is not working at all when used by on browser Javascript because the CORS headers are only present in the OPTION request, but not in the subsequent WebDAV methods. * This behavior is caused by a erroneous json_decode call while retriving the user's domains whitelist. It return an object, so the is_array always fails and no header are sent. * Add Access-Control-Expose-Headers - to allow clients to access certain headers * Adding many headers as allowed headers + add capability to read additional allowed headers from config.php --- apps/dav/lib/Connector/Sabre/CorsPlugin.php | 189 +++++++++ .../dav/lib/Connector/Sabre/ServerFactory.php | 1 + apps/dav/lib/Server.php | 3 + .../unit/Connector/Sabre/CorsPluginTest.php | 375 ++++++++++++++++++ .../DependencyInjection/DIContainer.php | 3 +- .../Middleware/Security/CORSMiddleware.php | 57 ++- lib/private/Route/Router.php | 35 ++ lib/private/legacy/OC_Response.php | 196 +++++++++ lib/public/Util.php | 55 +++ settings/Controller/CorsController.php | 156 ++++++++ .../Security/CORSMiddlewareTest.php | 61 ++- 11 files changed, 1079 insertions(+), 52 deletions(-) create mode 100644 apps/dav/lib/Connector/Sabre/CorsPlugin.php create mode 100644 apps/dav/tests/unit/Connector/Sabre/CorsPluginTest.php create mode 100644 settings/Controller/CorsController.php 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')); } }