From e3564e6c53bceb2dbe23dd1fbfec8b4696f7454d Mon Sep 17 00:00:00 2001 From: noveens Date: Fri, 21 Jul 2017 15:34:06 +0530 Subject: [PATCH] Enabled CORS on ownCloud --- apps/dav/lib/Connector/Sabre/CorsPlugin.php | 109 +++++++++++ .../dav/lib/Connector/Sabre/ServerFactory.php | 1 + apps/dav/lib/Server.php | 2 + apps/provisioning_api/appinfo/routes.php | 3 + apps/provisioning_api/lib/Users.php | 8 +- core/Controller/CloudController.php | 4 +- .../DependencyInjection/DIContainer.php | 3 +- .../Middleware/Security/CORSMiddleware.php | 32 +++- lib/private/Route/Router.php | 35 ++++ lib/private/Settings/SettingsManager.php | 6 + lib/private/legacy/api.php | 15 +- lib/private/legacy/response.php | 78 +++++++- lib/public/API.php | 4 +- ocs/v1.php | 1 - settings/Application.php | 1 + settings/Controller/CorsController.php | 158 ++++++++++++++++ settings/Panels/Personal/Cors.php | 75 ++++++++ settings/js/panels/cors.js | 33 ++++ settings/routes.php | 3 + settings/templates/panels/personal/cors.php | 60 ++++++ .../Controller/CorsControllerTest.php | 179 ++++++++++++++++++ .../Security/CORSMiddlewareTest.php | 107 +++++++++-- 22 files changed, 880 insertions(+), 37 deletions(-) create mode 100644 apps/dav/lib/Connector/Sabre/CorsPlugin.php create mode 100644 settings/Controller/CorsController.php create mode 100644 settings/Panels/Personal/Cors.php create mode 100644 settings/js/panels/cors.js create mode 100644 settings/templates/panels/personal/cors.php create mode 100644 tests/Settings/Controller/CorsControllerTest.php diff --git a/apps/dav/lib/Connector/Sabre/CorsPlugin.php b/apps/dav/lib/Connector/Sabre/CorsPlugin.php new file mode 100644 index 000000000000..50f1bb6607f3 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/CorsPlugin.php @@ -0,0 +1,109 @@ + + * + * @copyright Copyright (c) 2017, 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 Sabre\HTTP\ResponseInterface; +use Sabre\HTTP\RequestInterface; + +/** + * Class CorsPlugin is a plugin which adds CORS headers to the responses + */ +class CorsPlugin extends \Sabre\DAV\ServerPlugin { + + /** + * Reference to main server object + * + * @var \Sabre\DAV\Server + */ + private $server; + + /** + * Reference to logged in user's session + * + * @var \OCP\IUserSession + */ + private $userSession; + + /** + * @param \OCP\IUserSession $userSession + */ + public function __construct(\OCP\IUserSession $userSession) { + $this->userSession = $userSession; + $this->extraHeaders['Access-Control-Allow-Headers'] = ["X-OC-Mtime", "OC-Checksum", "OC-Total-Length", "Depth", "Destination", "Overwrite"]; + $this->extraHeaders['Access-Control-Allow-Methods'] = ["MOVE", "COPY"]; + } + + /** + * 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; + + $this->server->on('beforeMethod', [$this, 'setCorsHeaders']); + $this->server->on('beforeMethod:OPTIONS', [$this, 'setOptionsRequestHeaders']); + } + + /** + * This method sets the cors headers for all requests + * + * @return void + */ + public function setCorsHeaders(RequestInterface $request, ResponseInterface $response) { + if ($request->getHeader('origin') !== null && !is_null($this->userSession->getUser())) { + $requesterDomain = $request->getHeader('origin'); + $userId = $this->userSession->getUser()->getUID(); + $response = \OC_Response::setCorsHeaders($userId, $requesterDomain, $response, null, $this->extraHeaders); + } + } + + /** + * Handles the OPTIONS request + * + * @param RequestInterface $request + * @param ResponseInterface $response + * + * @return false + */ + 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->extraHeaders); + + // 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; + } + } +} diff --git a/apps/dav/lib/Connector/Sabre/ServerFactory.php b/apps/dav/lib/Connector/Sabre/ServerFactory.php index b61f50548ee2..00c828095f9f 100644 --- a/apps/dav/lib/Connector/Sabre/ServerFactory.php +++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php @@ -100,6 +100,7 @@ public function createServer($baseUri, $server->setBaseUri($baseUri); // Load plugins + $server->addPlugin(new \OCA\DAV\Connector\Sabre\CorsPlugin($this->userSession)); $server->addPlugin(new \OCA\DAV\Connector\Sabre\MaintenancePlugin($this->config)); $server->addPlugin(new \OCA\DAV\Connector\Sabre\ValidateRequestPlugin('webdav')); $server->addPlugin(new \OCA\DAV\Connector\Sabre\BlockLegacyClientPlugin($this->config)); diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index 6f5b140e1dc3..9ab1a1cddf37 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -32,6 +32,7 @@ use OCA\DAV\Connector\Sabre\BlockLegacyClientPlugin; 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; @@ -84,6 +85,7 @@ public function __construct(IRequest $request, $baseUri) { $this->server->addPlugin(new MaintenancePlugin($config)); $this->server->addPlugin(new ValidateRequestPlugin('dav')); $this->server->addPlugin(new BlockLegacyClientPlugin($config)); + $this->server->addPlugin(new CorsPlugin(\OC::$server->getUserSession())); $authPlugin = new Plugin(); $authPlugin->addBackend(new PublicAuth()); $this->server->addPlugin($authPlugin); diff --git a/apps/provisioning_api/appinfo/routes.php b/apps/provisioning_api/appinfo/routes.php index dc588e951a56..4bbd8abca9aa 100644 --- a/apps/provisioning_api/appinfo/routes.php +++ b/apps/provisioning_api/appinfo/routes.php @@ -40,6 +40,7 @@ \OC::$server->getLogger(), \OC::$server->getTwoFactorAuthManager() ); + API::register('get', '/cloud/users', [$users, 'getUsers'], 'provisioning_api', API::SUBADMIN_AUTH); API::register('post', '/cloud/users', [$users, 'addUser'], 'provisioning_api', API::SUBADMIN_AUTH); API::register('get', '/cloud/users/{userid}', [$users, 'getUser'], 'provisioning_api', API::USER_AUTH); @@ -60,6 +61,7 @@ \OC::$server->getUserSession(), \OC::$server->getRequest() ); + API::register('get', '/cloud/groups', [$groups, 'getGroups'], 'provisioning_api', API::SUBADMIN_AUTH); API::register('post', '/cloud/groups', [$groups, 'addGroup'], 'provisioning_api', API::SUBADMIN_AUTH); API::register('get', '/cloud/groups/{groupid}', [$groups, 'getGroup'], 'provisioning_api', API::SUBADMIN_AUTH); @@ -68,6 +70,7 @@ // Apps $apps = new Apps(\OC::$server->getAppManager()); + API::register('get', '/cloud/apps', [$apps, 'getApps'], 'provisioning_api', API::ADMIN_AUTH); API::register('get', '/cloud/apps/{appid}', [$apps, 'getAppInfo'], 'provisioning_api', API::ADMIN_AUTH); API::register('post', '/cloud/apps/{appid}', [$apps, 'enable'], 'provisioning_api', API::ADMIN_AUTH); diff --git a/apps/provisioning_api/lib/Users.php b/apps/provisioning_api/lib/Users.php index 9c31498a2b2f..80346b39bae3 100644 --- a/apps/provisioning_api/lib/Users.php +++ b/apps/provisioning_api/lib/Users.php @@ -100,7 +100,7 @@ public function getUsers() { } if($offset === null) { - $offset = 0; + $offset = 0; } $users = []; @@ -153,7 +153,7 @@ public function addUser() { return new Result(null, 106, 'no group specified (required for subadmins)'); } } - + try { $newUser = $this->userManager->createUser($userId, $password); $this->logger->info('Successful addUser call with userid: '.$userId, ['app' => 'ocs_api']); @@ -219,7 +219,7 @@ public function getUser($parameters) { return new Result($data); } - /** + /** * edit users * * @param array $parameters @@ -440,7 +440,7 @@ public function getUsersGroups($parameters) { return new Result(null, 997); } } - + } /** diff --git a/core/Controller/CloudController.php b/core/Controller/CloudController.php index 21edae5ad516..9cbdd2899100 100644 --- a/core/Controller/CloudController.php +++ b/core/Controller/CloudController.php @@ -36,6 +36,7 @@ public function __construct($appName, IRequest $request) { /** * @NoAdminRequired * @NoCSRFRequired + * @CORS * * @return array */ @@ -49,7 +50,7 @@ public function getCapabilities() { 'string' => \OC_Util::getVersionString(), 'edition' => \OC_Util::getEditionString(), ]; - + $result['capabilities'] = \OC::$server->getCapabilitiesManager()->getCapabilities(); return ['data' => $result]; @@ -58,6 +59,7 @@ public function getCapabilities() { /** * @NoAdminRequired * @NoCSRFRequired + * @CORS * * @return array */ diff --git a/lib/private/AppFramework/DependencyInjection/DIContainer.php b/lib/private/AppFramework/DependencyInjection/DIContainer.php index 5a73a10a82af..fe9d167326c2 100644 --- a/lib/private/AppFramework/DependencyInjection/DIContainer.php +++ b/lib/private/AppFramework/DependencyInjection/DIContainer.php @@ -360,7 +360,8 @@ public function __construct($appName, $urlParams = []){ return new CORSMiddleware( $c['Request'], $c['ControllerMethodReflector'], - $c['OCP\IUserSession'] + $c['OCP\IUserSession'], + $c['OCP\IConfig'] ); }); diff --git a/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php index 576b8c356a59..26368a648992 100644 --- a/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php +++ b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php @@ -28,13 +28,14 @@ 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\JSONResponse; use OCP\AppFramework\Http\Response; use OCP\AppFramework\Middleware; use OCP\IRequest; +use OCP\IUserSession; +use OCP\IConfig; /** * This middleware sets the correct CORS headers on a response if the @@ -55,21 +56,29 @@ class CORSMiddleware extends Middleware { private $reflector; /** - * @var Session + * @var IUserSession */ private $session; + /** + * @var IConfig + */ + private $config; + /** * @param IRequest $request * @param ControllerMethodReflector $reflector - * @param Session $session + * @param IUserSession $session + * @param IConfig $config */ public function __construct(IRequest $request, ControllerMethodReflector $reflector, - Session $session) { + IUserSession $session, + IConfig $config) { $this->request = $request; $this->reflector = $reflector; $this->session = $session; + $this->config = $config; } /** @@ -114,9 +123,17 @@ public function beforeController($controller, $methodName){ */ public function afterController($controller, $methodName, Response $response){ // only react if its a CORS request and if the request sends origin and + $userId = null; + if (!is_null($this->session->getUser())) { + $userId = $this->session->getUser()->getUID(); + } - if(isset($this->request->server['HTTP_ORIGIN']) && - $this->reflector->hasAnnotation('CORS')) { + if($this->request->getHeader("Origin") !== null && + $this->reflector->hasAnnotation('CORS') && !is_null($userId)) { + + $requesterDomain = $this->request->getHeader("Origin"); + + \OC_Response::setCorsHeaders($userId, $requesterDomain, $response, $this->config); // allow credentials headers must not be true or CSRF is possible // otherwise @@ -128,9 +145,6 @@ public function afterController($controller, $methodName, Response $response){ 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 258bff72559f..71a66ef31765 100644 --- a/lib/private/Route/Router.php +++ b/lib/private/Route/Router.php @@ -271,6 +271,41 @@ public function match($url) { } $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/Settings/SettingsManager.php b/lib/private/Settings/SettingsManager.php index c4fee9228785..3724eb7390a2 100644 --- a/lib/private/Settings/SettingsManager.php +++ b/lib/private/Settings/SettingsManager.php @@ -46,6 +46,7 @@ use OC\Settings\Panels\Personal\Clients; use OC\Settings\Panels\Personal\Version; use OC\Settings\Panels\Personal\Tokens; +use OC\Settings\Panels\Personal\Cors; use OC\Settings\Panels\Personal\Quota; use OC\Settings\Panels\Admin\BackgroundJobs; use OC\Settings\Panels\Admin\Certificates; @@ -246,6 +247,7 @@ private function getBuiltInPanels($type) { LegacyPersonal::class, Version::class, Tokens::class, + Cors::class, Quota::class ]; } @@ -268,6 +270,10 @@ public function getBuiltInPanel($className) { Clients::class => new Clients($this->config, $this->defaults), Version::class => new Version(), Tokens::class => new Tokens(), + Cors::class => new Cors( + $this->userSession, + $this->urlGenerator, + $this->config), Quota::class => new Quota($this->helper), // Admin BackgroundJobs::class => new BackgroundJobs($this->config), diff --git a/lib/private/legacy/api.php b/lib/private/legacy/api.php index 80993630f543..6436d8479824 100644 --- a/lib/private/legacy/api.php +++ b/lib/private/legacy/api.php @@ -115,11 +115,12 @@ class OC_API { * @param int $authLevel the level of authentication required for the call * @param array $defaults * @param array $requirements + * @param boolean $cors whether to enable cors for this route */ public static function register($method, $url, $action, $app, $authLevel = API::USER_AUTH, $defaults = [], - $requirements = []) { + $requirements = [], $cors = true) { $name = strtolower($method).$url; $name = str_replace(['/', '{', '}'], '_', $name); if(!isset(self::$actions[$name])) { @@ -133,7 +134,7 @@ public static function register($method, $url, $action, $app, self::$actions[$name] = []; OC::$server->getRouter()->useCollection($oldCollection); } - self::$actions[$name][] = ['app' => $app, 'action' => $action, 'authlevel' => $authLevel]; + self::$actions[$name][] = ['app' => $app, 'action' => $action, 'authlevel' => $authLevel, 'cors' => $cors]; } /** @@ -179,6 +180,16 @@ public static function call($parameters) { ]; } $response = self::mergeResponses($responses); + + // If CORS is set to active for some method, try to add CORS headers + if (self::$actions[$name][0]['cors'] && + !is_null(\OC::$server->getUserSession()->getUser()) && + !is_null(\OC::$server->getRequest()->getHeader('Origin'))) { + $requesterDomain = \OC::$server->getRequest()->getHeader('Origin'); + $userId = \OC::$server->getUserSession()->getUser()->getUID(); + $response = \OC_Response::setCorsHeaders($userId, $requesterDomain, $response); + } + $format = self::requestedFormat(); if (self::$logoutRequired) { \OC::$server->getUserSession()->logout(); diff --git a/lib/private/legacy/response.php b/lib/private/legacy/response.php index bb6b53e90782..485240c4fc8d 100644 --- a/lib/private/legacy/response.php +++ b/lib/private/legacy/response.php @@ -252,7 +252,7 @@ public static function addSecurityHeaders() { . 'frame-src *; ' . 'img-src * data: blob:; ' . 'font-src \'self\' data:; ' - . 'media-src *; ' + . 'media-src *; ' . 'connect-src *'; header('Content-Security-Policy:' . $policy); @@ -268,4 +268,80 @@ public static function addSecurityHeaders() { } } + /** + * This function adds the CORS headers if the requester domain is white-listed + * + * @param string $userId + * @param string $domain + * @param Sabre\HTTP\ResponseInterface $response + * @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 Sabre\HTTP\ResponseInterface $response + */ + public static function setCorsHeaders($userId, $domain, $response, $config = null, $headers = []) { + if (is_null($config)) { + $config = \OC::$server->getConfig(); + } + $allowedDomains = json_decode($config->getUserValue($userId, 'core', 'domains')); + if (in_array($domain, $allowedDomains)) { + // TODO: infer allowed verbs from existing known routes + $allHeaders['Access-Control-Allow-Headers'] = ["authorization", "OCS-APIREQUEST", "Origin", "X-Requested-With", "Content-Type", "Access-Control-Allow-Origin"]; + $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_merge($allHeaders[$key], $value); + } + } + + foreach ($allHeaders as $key => $value) { + $response->addHeader($key, implode(",", $value)); + } + } + return $response; + } + + /** + * 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"] + * ] + * + * @return Sabre\HTTP\ResponseInterface $response + */ + public static function setOptionsRequestHeaders($response, $headers = []) { + // TODO: infer allowed verbs from existing known routes + $allHeaders['Access-Control-Allow-Headers'] = ["authorization", "OCS-APIREQUEST", "Origin", "X-Requested-With", "Content-Type", "Access-Control-Allow-Origin"]; + $allHeaders['Access-Control-Allow-Origin'] = ['*']; + $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_merge($allHeaders[$key], $value); + } + } + + foreach ($allHeaders as $key => $value) { + $response->addHeader($key, implode(",", $value)); + } + + return $response; + } + } diff --git a/lib/public/API.php b/lib/public/API.php index 9062d458457d..a29fcfdb1769 100644 --- a/lib/public/API.php +++ b/lib/public/API.php @@ -69,8 +69,8 @@ class API { * @since 5.0.0 */ public static function register($method, $url, $action, $app, $authLevel = self::USER_AUTH, - $defaults = [], $requirements = []){ - \OC_API::register($method, $url, $action, $app, $authLevel, $defaults, $requirements); + $defaults = [], $requirements = [], $cors = true){ + \OC_API::register($method, $url, $action, $app, $authLevel, $defaults, $requirements, $cors); } } diff --git a/ocs/v1.php b/ocs/v1.php index 2573568f1be6..f9cc4dc640ba 100644 --- a/ocs/v1.php +++ b/ocs/v1.php @@ -105,4 +105,3 @@ } catch (Exception $ex) { OC_API::respond($ex->getResult(), OC_API::requestedFormat()); } - diff --git a/settings/Application.php b/settings/Application.php index 41df506135e2..ff30ee77e2f3 100644 --- a/settings/Application.php +++ b/settings/Application.php @@ -34,6 +34,7 @@ use OC\Files\View; use OC\Server; use OC\AppFramework\Utility\TimeFactory; +use OC\Settings\Controller\CorsController; use OC\Settings\Controller\SettingsPageController; use OC\Settings\Controller\AppSettingsController; use OC\Settings\Controller\AuthSettingsController; diff --git a/settings/Controller/CorsController.php b/settings/Controller/CorsController.php new file mode 100644 index 000000000000..5dffa6b7de1e --- /dev/null +++ b/settings/Controller/CorsController.php @@ -0,0 +1,158 @@ + + */ + +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/settings/Panels/Personal/Cors.php b/settings/Panels/Personal/Cors.php new file mode 100644 index 000000000000..9b67cb007538 --- /dev/null +++ b/settings/Panels/Personal/Cors.php @@ -0,0 +1,75 @@ + + */ + +namespace OC\Settings\Panels\Personal; + +use OCP\IURLGenerator; +use OCP\IUserSession; +use OCP\IConfig; +use OCP\Settings\ISettings; +use OCP\Template; + +class Cors implements ISettings { + + /** + * @var IUserSession + */ + protected $userSession; + + /** + * @var IURLGenerator + */ + protected $urlGenerator; + + /** @var IConfig */ + private $config; + + public function __construct( + IUserSession $userSession, + IURLGenerator $urlGenerator, + IConfig $config) { + + $this->config = $config; + $this->userSession = $userSession; + $this->urlGenerator = $urlGenerator; + } + + public function getSectionID() { + return 'security'; + } + + /** + * @return Template + */ + public function getPanel() { + $userId = $this->userSession->getUser()->getUID(); + $domains = json_decode($this->config->getUserValue($userId, 'core', 'domains')); + + $t = new Template('settings', 'panels/personal/cors'); + $t->assign('user_id', $userId); + $t->assign('domains', $domains); + $t->assign('urlGenerator', $this->urlGenerator); + return $t; + } + + + public function getPriority() { + return 20; + } + +} diff --git a/settings/js/panels/cors.js b/settings/js/panels/cors.js new file mode 100644 index 000000000000..af6c1aa4171e --- /dev/null +++ b/settings/js/panels/cors.js @@ -0,0 +1,33 @@ +$(document).ready(function () { + $('.removeDomainButton').on('click', function () { + var id = $(this).attr('data-id'); + var confirmText = $(this).attr('data-confirm'); + var token = OC.requestToken; + var $el = $(this); + + OC.dialogs.confirm( + t('settings', confirmText), t('settings','CORS'), + function (result) { + if (result) { + $.ajax({ + type: 'DELETE', + url: OC.generateUrl('/settings/domains/{id}', {id: id}), + data: { + requesttoken: token + } + }).success(function() { + var numDomains = $("#cors .grid tbody tr").length; + if ($el.closest('tr').length === 1 && numDomains === 1) { + // Means only one domain row remains and that is to be deleted + // Show the No domains text + $("#noDomains").text("No Domains."); + // Remove the domain listing table + $("#cors .grid").remove(); + } + $el.closest('tr').remove(); + }); + } + }, true + ); + }); +}); diff --git a/settings/routes.php b/settings/routes.php index 46d3d5643a0d..c54892fdfb7b 100644 --- a/settings/routes.php +++ b/settings/routes.php @@ -63,6 +63,9 @@ ['name' => 'SettingsPage#getPersonal', 'url' => '/settings/personal', 'verb' => 'GET'], ['name' => 'SettingsPage#getAdmin', 'url' => '/settings/admin', 'verb' => 'GET'], ['name' => 'Users#changeMail', 'url' => '/settings/mailaddress/change/{token}/{userId}', 'verb' => 'GET'], + ['name' => 'Cors#getDomains', 'url' => '/settings/domains', 'verb' => 'GET'], + ['name' => 'Cors#addDomain', 'url' => '/settings/domains', 'verb' => 'POST'], + ['name' => 'Cors#removeDomain', 'url' => '/settings/domains/{id}', 'verb' => 'DELETE'] ] ]); diff --git a/settings/templates/panels/personal/cors.php b/settings/templates/panels/personal/cors.php new file mode 100644 index 000000000000..8a45d570d043 --- /dev/null +++ b/settings/templates/panels/personal/cors.php @@ -0,0 +1,60 @@ + + */ + +script('settings', 'panels/cors'); + +?> + +
+

t('CORS')); ?>

+ +

t('White-listed Domains')); ?>

+

+ t('No Domains.')); + } ?> +

+ + + + + + + + + + + $domain) { ?> + + + + + + +
t('Domain')); ?> 
+ +
+ + +

t('Add Domain')); ?>

+
+ + + +
+
diff --git a/tests/Settings/Controller/CorsControllerTest.php b/tests/Settings/Controller/CorsControllerTest.php new file mode 100644 index 000000000000..925435012615 --- /dev/null +++ b/tests/Settings/Controller/CorsControllerTest.php @@ -0,0 +1,179 @@ + + * + * @copyright Copyright (c) 2015, ownCloud, Inc. + * @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 Tests\Settings\Controller; + +use OC\Settings\Controller\CorsController; +use OCP\AppFramework\Http\JSONResponse; +use Test\TestCase; +use OCP\IRequest; +use OCP\IConfig; +use OCP\ILogger; +use OCP\IUser; +use OCP\IUserSession; +use OCP\IURLGenerator; +use OCP\AppFramework\Http\RedirectResponse; + +/** + * Class CorsControllerTest + * + * @package Tests\Settings\Controller + */ +class CorsControllerTest extends TestCase { + /** @var CorsController */ + private $corsController; + + /** @var IRequest */ + private $request; + + /** @var ILogger */ + private $logger; + + /** @var IURLGenerator */ + private $urlGenerator; + + /** @var IConfig */ + private $config; + + /** @var IUser */ + private $userSession; + + public function setUp() { + parent::setUp(); + + $this->request = $this->createMock(IRequest::class); + $this->logger = $this->createMock(ILogger::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('user'); + + $this->userSession = $this->createMock(IUserSession::class); + $this->userSession->method('getUser')->willReturn($user); + + $this->config = $this->createMock(IConfig::class); + $this->config->method('getUserValue')->willReturn('["http:\/\/www.test.com"]'); + $this->config->method('setUserValue')->willReturn(true); + + $this->corsController = new CorsController( + 'core', + $this->request, + $this->userSession, + $this->logger, + $this->urlGenerator, + $this->config + ); + } + + public function testAddInvalidDomain() { + // Since this domain is invalid, + // the success message that the domain is white-listed, wouldn't be triggered + $this->logger + ->expects($this->never()) + ->method('debug'); + + $this->config + ->expects($this->never()) + ->method("setUserValue"); + + $response = $this->corsController->addDomain("non-valid domain"); + + $expectedResponse = new RedirectResponse( + $this->urlGenerator->linkToRouteAbsolute( + 'settings.SettingsPage.getPersonal', + ['sectionid' => 'security'] + ) . '#cors' + ); + + $this->assertEquals($response, $expectedResponse); + } + + public function testAddValidDomain() { + // Since this domain is valid, + // the success message that the domain is white-listed, would be triggered exactly once + $this->logger + ->expects($this->once()) + ->method('debug'); + + $this->config + ->expects($this->once()) + ->method("setUserValue"); + + $response = $this->corsController->addDomain("http://www.test1.com"); + + $expectedResponse = new RedirectResponse( + $this->urlGenerator->linkToRouteAbsolute( + 'settings.SettingsPage.getPersonal', + ['sectionid' => 'security'] + ) . '#cors' + ); + + $this->assertEquals($response, $expectedResponse); + } + + public function testRemoveInvalidDomain() { + // Since this domain id passed is invalid, + // the error message that invalid domain ID passed, would be triggered + $this->config + ->expects($this->never()) + ->method("setUserValue"); + + // The argument for removing domain is the ID of the white-listed domain + // and not the domain itself + $response = $this->corsController->removeDomain(100); + + $expectedResponse = new RedirectResponse( + $this->urlGenerator->linkToRouteAbsolute( + 'settings.SettingsPage.getPersonal', + ['sectionid' => 'security'] + ) . '#cors' + ); + + $this->assertEquals($response, $expectedResponse); + } + + public function testRemoveValidDomain() { + // Since this domain-ID is valid, + // the error message that invalid domain ID passed, would never be triggered + $this->config + ->expects($this->once()) + ->method("setUserValue"); + + // The argument for removing domain is the ID of the white-listed domain + // and not the domain itself + $response = $this->corsController->removeDomain(0); + + $expectedResponse = new RedirectResponse( + $this->urlGenerator->linkToRouteAbsolute( + 'settings.SettingsPage.getPersonal', + ['sectionid' => 'security'] + ) . '#cors' + ); + + $this->assertEquals($response, $expectedResponse); + } + + public function testGetDomains() { + // since their are no domains, empty JSON response will be sent back + $expected = new JSONResponse(["http://www.test.com"]); + $this->assertEquals($expected, $this->corsController->getDomains()); + } +} diff --git a/tests/lib/AppFramework/Middleware/Security/CORSMiddlewareTest.php b/tests/lib/AppFramework/Middleware/Security/CORSMiddlewareTest.php index 324d541e0340..1bb9e2b344eb 100644 --- a/tests/lib/AppFramework/Middleware/Security/CORSMiddlewareTest.php +++ b/tests/lib/AppFramework/Middleware/Security/CORSMiddlewareTest.php @@ -18,8 +18,14 @@ use OC\AppFramework\Middleware\Security\Exceptions\SecurityException; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\Response; +use OCP\IUserSession; +use OCP\IUser; +use OCP\IConfig; +/** + * Class CORSMiddlewareTest + */ class CORSMiddlewareTest extends \Test\TestCase { private $reflector; @@ -27,10 +33,23 @@ class CORSMiddlewareTest extends \Test\TestCase { protected function setUp() { parent::setUp(); + + $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->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->fakeSession = $userSession; } /** @@ -40,18 +59,24 @@ public function testSetCORSAPIHeader() { $request = new Request( [ 'server' => [ - 'HTTP_ORIGIN' => 'test' + 'HTTP_ORIGIN' => 'http://www.test.com' ] ], $this->createMock('\OCP\Security\ISecureRandom'), - $this->createMock('\OCP\IConfig') + $this->config ); + $this->reflector->reflect($this, __FUNCTION__); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session); + $middleware = new CORSMiddleware( + $request, + $this->reflector, + $this->fakeSession, + $this->config + ); $response = $middleware->afterController($this, __FUNCTION__, new Response()); $headers = $response->getHeaders(); - $this->assertEquals('test', $headers['Access-Control-Allow-Origin']); + $this->assertEquals('http://www.test.com', $headers['Access-Control-Allow-Origin']); } @@ -65,7 +90,12 @@ public function testNoAnnotationNoCORSHEADER() { $this->createMock('\OCP\Security\ISecureRandom'), $this->createMock('\OCP\IConfig') ); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session); + $middleware = new CORSMiddleware( + $request, + $this->reflector, + $this->fakeSession, + $this->config + ); $response = $middleware->afterController($this, __FUNCTION__, new Response()); $headers = $response->getHeaders(); @@ -83,7 +113,12 @@ public function testNoOriginHeaderNoCORSHEADER() { $this->createMock('\OCP\IConfig') ); $this->reflector->reflect($this, __FUNCTION__); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session); + $middleware = new CORSMiddleware( + $request, + $this->reflector, + $this->fakeSession, + $this->config + ); $response = $middleware->afterController($this, __FUNCTION__, new Response()); $headers = $response->getHeaders(); @@ -99,14 +134,19 @@ public function testCorsIgnoredIfWithCredentialsHeaderPresent() { $request = new Request( [ 'server' => [ - 'HTTP_ORIGIN' => 'test' + 'HTTP_ORIGIN' => 'http://www.test.com', ] ], $this->createMock('\OCP\Security\ISecureRandom'), $this->createMock('\OCP\IConfig') ); $this->reflector->reflect($this, __FUNCTION__); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session); + $middleware = new CORSMiddleware( + $request, + $this->reflector, + $this->fakeSession, + $this->config + ); $response = new Response(); $response->addHeader('AcCess-control-Allow-Credentials ', 'TRUE'); @@ -124,7 +164,12 @@ public function testNoCORSShouldAllowCookieAuth() { $this->createMock('\OCP\IConfig') ); $this->reflector->reflect($this, __FUNCTION__); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session); + $middleware = new CORSMiddleware( + $request, + $this->reflector, + $this->fakeSession, + $this->config + ); $this->session->expects($this->never()) ->method('logout'); $this->session->expects($this->never()) @@ -146,7 +191,7 @@ public function testCORSShouldRelogin() { 'PHP_AUTH_PW' => 'pass' ]], $this->createMock('\OCP\Security\ISecureRandom'), - $this->createMock('\OCP\IConfig') + $this->config ); $this->session->expects($this->once()) ->method('logout'); @@ -155,7 +200,12 @@ public function testCORSShouldRelogin() { ->with($this->equalTo('user'), $this->equalTo('pass')) ->will($this->returnValue(true)); $this->reflector->reflect($this, __FUNCTION__); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session); + $middleware = new CORSMiddleware( + $request, + $this->reflector, + $this->session, + $this->config + ); $middleware->beforeController($this, __FUNCTION__, new Response()); } @@ -180,7 +230,12 @@ public function testCORSShouldFailIfPasswordLoginIsForbidden() { ->with($this->equalTo('user'), $this->equalTo('pass')) ->will($this->throwException(new \OC\Authentication\Exceptions\PasswordLoginForbiddenException)); $this->reflector->reflect($this, __FUNCTION__); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session); + $middleware = new CORSMiddleware( + $request, + $this->reflector, + $this->session, + $this->config + ); $middleware->beforeController($this, __FUNCTION__, new Response()); } @@ -205,7 +260,12 @@ public function testCORSShouldNotAllowCookieAuth() { ->with($this->equalTo('user'), $this->equalTo('pass')) ->will($this->returnValue(false)); $this->reflector->reflect($this, __FUNCTION__); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session); + $middleware = new CORSMiddleware( + $request, + $this->reflector, + $this->session, + $this->config + ); $middleware->beforeController($this, __FUNCTION__, new Response()); } @@ -219,7 +279,12 @@ public function testAfterExceptionWithSecurityExceptionNoStatus() { $this->createMock('\OCP\Security\ISecureRandom'), $this->createMock('\OCP\IConfig') ); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session); + $middleware = new CORSMiddleware( + $request, + $this->reflector, + $this->fakeSession, + $this->config + ); $response = $middleware->afterException($this, __FUNCTION__, new SecurityException('A security exception')); $expected = new JSONResponse(['message' => 'A security exception'], 500); @@ -235,7 +300,12 @@ public function testAfterExceptionWithSecurityExceptionWithStatus() { $this->createMock('\OCP\Security\ISecureRandom'), $this->createMock('\OCP\IConfig') ); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session); + $middleware = new CORSMiddleware( + $request, + $this->reflector, + $this->fakeSession, + $this->config + ); $response = $middleware->afterException($this, __FUNCTION__, new SecurityException('A security exception', 501)); $expected = new JSONResponse(['message' => 'A security exception'], 501); @@ -255,7 +325,12 @@ public function testAfterExceptionWithRegularException() { $this->createMock('\OCP\Security\ISecureRandom'), $this->createMock('\OCP\IConfig') ); - $middleware = new CORSMiddleware($request, $this->reflector, $this->session); + $middleware = new CORSMiddleware( + $request, + $this->reflector, + $this->fakeSession, + $this->config + ); $middleware->afterException($this, __FUNCTION__, new \Exception('A regular exception')); }