diff options
Diffstat (limited to 'lib/private/AppFramework/Middleware/Security')
20 files changed, 1387 insertions, 0 deletions
diff --git a/lib/private/AppFramework/Middleware/Security/BruteForceMiddleware.php b/lib/private/AppFramework/Middleware/Security/BruteForceMiddleware.php new file mode 100644 index 00000000000..4b4425517e0 --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/BruteForceMiddleware.php @@ -0,0 +1,137 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security; + +use OC\AppFramework\Utility\ControllerMethodReflector; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\BruteForceProtection; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\TooManyRequestsResponse; +use OCP\AppFramework\Middleware; +use OCP\AppFramework\OCS\OCSException; +use OCP\AppFramework\OCSController; +use OCP\IRequest; +use OCP\Security\Bruteforce\IThrottler; +use OCP\Security\Bruteforce\MaxDelayReached; +use Psr\Log\LoggerInterface; +use ReflectionMethod; + +/** + * Class BruteForceMiddleware performs the bruteforce protection for controllers + * that are annotated with @BruteForceProtection(action=$action) whereas $action + * is the action that should be logged within the database. + * + * @package OC\AppFramework\Middleware\Security + */ +class BruteForceMiddleware extends Middleware { + private int $delaySlept = 0; + + public function __construct( + protected ControllerMethodReflector $reflector, + protected IThrottler $throttler, + protected IRequest $request, + protected LoggerInterface $logger, + ) { + } + + /** + * {@inheritDoc} + */ + public function beforeController($controller, $methodName) { + parent::beforeController($controller, $methodName); + + if ($this->reflector->hasAnnotation('BruteForceProtection')) { + $action = $this->reflector->getAnnotationParameter('BruteForceProtection', 'action'); + $this->delaySlept += $this->throttler->sleepDelayOrThrowOnMax($this->request->getRemoteAddress(), $action); + } else { + $reflectionMethod = new ReflectionMethod($controller, $methodName); + $attributes = $reflectionMethod->getAttributes(BruteForceProtection::class); + + if (!empty($attributes)) { + $remoteAddress = $this->request->getRemoteAddress(); + + foreach ($attributes as $attribute) { + /** @var BruteForceProtection $protection */ + $protection = $attribute->newInstance(); + $action = $protection->getAction(); + $this->delaySlept += $this->throttler->sleepDelayOrThrowOnMax($remoteAddress, $action); + } + } + } + } + + /** + * {@inheritDoc} + */ + public function afterController($controller, $methodName, Response $response) { + if ($response->isThrottled()) { + try { + if ($this->reflector->hasAnnotation('BruteForceProtection')) { + $action = $this->reflector->getAnnotationParameter('BruteForceProtection', 'action'); + $ip = $this->request->getRemoteAddress(); + $this->throttler->registerAttempt($action, $ip, $response->getThrottleMetadata()); + $this->delaySlept += $this->throttler->sleepDelayOrThrowOnMax($ip, $action); + } else { + $reflectionMethod = new ReflectionMethod($controller, $methodName); + $attributes = $reflectionMethod->getAttributes(BruteForceProtection::class); + + if (!empty($attributes)) { + $ip = $this->request->getRemoteAddress(); + $metaData = $response->getThrottleMetadata(); + + foreach ($attributes as $attribute) { + /** @var BruteForceProtection $protection */ + $protection = $attribute->newInstance(); + $action = $protection->getAction(); + + if (!isset($metaData['action']) || $metaData['action'] === $action) { + $this->throttler->registerAttempt($action, $ip, $metaData); + $this->delaySlept += $this->throttler->sleepDelayOrThrowOnMax($ip, $action); + } + } + } else { + $this->logger->debug('Response for ' . get_class($controller) . '::' . $methodName . ' got bruteforce throttled but has no annotation nor attribute defined.'); + } + } + } catch (MaxDelayReached $e) { + if ($controller instanceof OCSController) { + throw new OCSException($e->getMessage(), Http::STATUS_TOO_MANY_REQUESTS); + } + + return new TooManyRequestsResponse(); + } + } + + if ($this->delaySlept) { + $response->addHeader('X-Nextcloud-Bruteforce-Throttled', $this->delaySlept . 'ms'); + } + + return parent::afterController($controller, $methodName, $response); + } + + /** + * @param Controller $controller + * @param string $methodName + * @param \Exception $exception + * @throws \Exception + * @return Response + */ + public function afterException($controller, $methodName, \Exception $exception): Response { + if ($exception instanceof MaxDelayReached) { + if ($controller instanceof OCSController) { + throw new OCSException($exception->getMessage(), Http::STATUS_TOO_MANY_REQUESTS); + } + + return new TooManyRequestsResponse(); + } + + throw $exception; + } +} diff --git a/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php new file mode 100644 index 00000000000..4453f5a7d4b --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php @@ -0,0 +1,175 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +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; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Middleware; +use OCP\IRequest; +use OCP\ISession; +use OCP\Security\Bruteforce\IThrottler; +use Psr\Log\LoggerInterface; +use ReflectionMethod; + +/** + * This middleware sets the correct CORS headers on a response if the + * controller has the @CORS annotation. This is needed for webapps that want + * to access an API and don't run on the same domain, see + * 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, + private readonly LoggerInterface $logger, + ) { + $this->request = $request; + $this->reflector = $reflector; + $this->session = $session; + $this->throttler = $throttler; + } + + /** + * This is being run in normal order before the controller is being + * called which allows several modifications and checks + * + * @param Controller $controller the controller that is being called + * @param string $methodName the name of the method that will be called on + * the controller + * @throws SecurityException + * @since 6.0.0 + */ + public function beforeController($controller, $methodName) { + $reflectionMethod = new ReflectionMethod($controller, $methodName); + + // ensure that @CORS annotated API routes are not used in conjunction + // with session authentication since this enables CSRF attack vectors + if ($this->hasAnnotationOrAttribute($reflectionMethod, 'CORS', CORS::class) + && (!$this->hasAnnotationOrAttribute($reflectionMethod, 'PublicPage', PublicPage::class) || $this->session->isLoggedIn())) { + $user = array_key_exists('PHP_AUTH_USER', $this->request->server) ? $this->request->server['PHP_AUTH_USER'] : null; + $pass = array_key_exists('PHP_AUTH_PW', $this->request->server) ? $this->request->server['PHP_AUTH_PW'] : null; + + // Allow to use the current session if a CSRF token is provided + if ($this->request->passesCSRFCheck()) { + return; + } + // Skip CORS check for requests with AppAPI auth. + if ($this->session->getSession() instanceof ISession && $this->session->getSession()->get('app_api') === true) { + return; + } + $this->session->logout(); + try { + if ($user === null || $pass === null || !$this->session->logClientIn($user, $pass, $this->request, $this->throttler)) { + throw new SecurityException('CORS requires basic auth', Http::STATUS_UNAUTHORIZED); + } + } catch (PasswordLoginForbiddenException $ex) { + throw new SecurityException('Password login forbidden, use token instead', Http::STATUS_UNAUTHORIZED); + } + } + } + + /** + * @template T + * + * @param ReflectionMethod $reflectionMethod + * @param string $annotationName + * @param class-string<T> $attributeClass + * @return boolean + */ + protected function hasAnnotationOrAttribute(ReflectionMethod $reflectionMethod, string $annotationName, string $attributeClass): bool { + if ($this->reflector->hasAnnotation($annotationName)) { + $this->logger->debug($reflectionMethod->getDeclaringClass()->getName() . '::' . $reflectionMethod->getName() . ' uses the @' . $annotationName . ' annotation and should use the #[' . $attributeClass . '] attribute instead'); + return true; + } + + + if (!empty($reflectionMethod->getAttributes($attributeClass))) { + return true; + } + + return false; + } + + /** + * This is being run after a successful controller method call and allows + * the manipulation of a Response object. The middleware is run in reverse order + * + * @param Controller $controller the controller that is being called + * @param string $methodName the name of the method that will be called on + * the controller + * @param Response $response the generated response from the controller + * @return Response a Response object + * @throws SecurityException + */ + public function afterController($controller, $methodName, Response $response) { + // only react if it's a CORS request and if the request sends origin and + + 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); + } + } + + $origin = $this->request->server['HTTP_ORIGIN']; + $response->addHeader('Access-Control-Allow-Origin', $origin); + } + } + return $response; + } + + /** + * If an SecurityException is being caught return a JSON error response + * + * @param Controller $controller the controller that is being called + * @param string $methodName the name of the method that will be called on + * the controller + * @param \Exception $exception the thrown exception + * @throws \Exception the passed in exception if it can't handle it + * @return Response a Response object or null in case that the exception could not be handled + */ + public function afterException($controller, $methodName, \Exception $exception) { + if ($exception instanceof SecurityException) { + $response = new JSONResponse(['message' => $exception->getMessage()]); + if ($exception->getCode() !== 0) { + $response->setStatus($exception->getCode()); + } else { + $response->setStatus(Http::STATUS_INTERNAL_SERVER_ERROR); + } + return $response; + } + + throw $exception; + } +} diff --git a/lib/private/AppFramework/Middleware/Security/CSPMiddleware.php b/lib/private/AppFramework/Middleware/Security/CSPMiddleware.php new file mode 100644 index 00000000000..e88c9563c00 --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/CSPMiddleware.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security; + +use OC\Security\CSP\ContentSecurityPolicyManager; +use OC\Security\CSP\ContentSecurityPolicyNonceManager; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\ContentSecurityPolicy; +use OCP\AppFramework\Http\EmptyContentSecurityPolicy; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Middleware; + +class CSPMiddleware extends Middleware { + + public function __construct( + private ContentSecurityPolicyManager $policyManager, + private ContentSecurityPolicyNonceManager $cspNonceManager, + ) { + } + + /** + * Performs the default CSP modifications that may be injected by other + * applications + * + * @param Controller $controller + * @param string $methodName + * @param Response $response + * @return Response + */ + public function afterController($controller, $methodName, Response $response): Response { + $policy = !is_null($response->getContentSecurityPolicy()) ? $response->getContentSecurityPolicy() : new ContentSecurityPolicy(); + + if (get_class($policy) === EmptyContentSecurityPolicy::class) { + return $response; + } + + $defaultPolicy = $this->policyManager->getDefaultPolicy(); + $defaultPolicy = $this->policyManager->mergePolicies($defaultPolicy, $policy); + + if ($this->cspNonceManager->browserSupportsCspV3()) { + $defaultPolicy->useJsNonce($this->cspNonceManager->getNonce()); + } + + $response->setContentSecurityPolicy($defaultPolicy); + + return $response; + } +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/AdminIpNotAllowedException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/AdminIpNotAllowedException.php new file mode 100644 index 00000000000..36eb8f18928 --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/AdminIpNotAllowedException.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +use OCP\AppFramework\Http; + +/** + * Class AdminIpNotAllowed is thrown when a resource has been requested by a + * an admin user connecting from an unauthorized IP address + * See configuration `allowed_admin_ranges` + * + * @package OC\AppFramework\Middleware\Security\Exceptions + */ +class AdminIpNotAllowedException extends SecurityException { + public function __construct(string $message) { + parent::__construct($message, Http::STATUS_FORBIDDEN); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/AppNotEnabledException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/AppNotEnabledException.php new file mode 100644 index 00000000000..53fbaaf5ed2 --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/AppNotEnabledException.php @@ -0,0 +1,22 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +use OCP\AppFramework\Http; + +/** + * Class AppNotEnabledException is thrown when a resource for an application is + * requested that is not enabled. + * + * @package OC\AppFramework\Middleware\Security\Exceptions + */ +class AppNotEnabledException extends SecurityException { + public function __construct() { + parent::__construct('App is not enabled', Http::STATUS_PRECONDITION_FAILED); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/CrossSiteRequestForgeryException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/CrossSiteRequestForgeryException.php new file mode 100644 index 00000000000..0c6a28134ca --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/CrossSiteRequestForgeryException.php @@ -0,0 +1,22 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +use OCP\AppFramework\Http; + +/** + * Class CrossSiteRequestForgeryException is thrown when a CSRF exception has + * been encountered. + * + * @package OC\AppFramework\Middleware\Security\Exceptions + */ +class CrossSiteRequestForgeryException extends SecurityException { + public function __construct() { + parent::__construct('CSRF check failed', Http::STATUS_PRECONDITION_FAILED); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/ExAppRequiredException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/ExAppRequiredException.php new file mode 100644 index 00000000000..77bc7efebac --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/ExAppRequiredException.php @@ -0,0 +1,18 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +use OCP\AppFramework\Http; + +/** + * Class ExAppRequiredException is thrown when an endpoint can only be called by an ExApp but the caller is not an ExApp. + */ +class ExAppRequiredException extends SecurityException { + public function __construct() { + parent::__construct('ExApp required', Http::STATUS_PRECONDITION_FAILED); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/LaxSameSiteCookieFailedException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/LaxSameSiteCookieFailedException.php new file mode 100644 index 00000000000..0380c6781aa --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/LaxSameSiteCookieFailedException.php @@ -0,0 +1,21 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +use OCP\AppFramework\Http; + +/** + * Class LaxSameSiteCookieFailedException is thrown when a request doesn't pass + * the required LaxCookie check on index.php + * + * @package OC\AppFramework\Middleware\Security\Exceptions + */ +class LaxSameSiteCookieFailedException extends SecurityException { + public function __construct() { + parent::__construct('Lax Same Site Cookie is invalid in request.', Http::STATUS_PRECONDITION_FAILED); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/NotAdminException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/NotAdminException.php new file mode 100644 index 00000000000..6252c914ac1 --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/NotAdminException.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +use OCP\AppFramework\Http; + +/** + * Class NotAdminException is thrown when a resource has been requested by a + * non-admin user that is not accessible to non-admin users. + * + * @package OC\AppFramework\Middleware\Security\Exceptions + */ +class NotAdminException extends SecurityException { + public function __construct(string $message) { + parent::__construct($message, Http::STATUS_FORBIDDEN); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/NotConfirmedException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/NotConfirmedException.php new file mode 100644 index 00000000000..ca30f736fbc --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/NotConfirmedException.php @@ -0,0 +1,21 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +use OCP\AppFramework\Http; + +/** + * Class NotConfirmedException is thrown when a resource has been requested by a + * user that has not confirmed their password in the last 30 minutes. + * + * @package OC\AppFramework\Middleware\Security\Exceptions + */ +class NotConfirmedException extends SecurityException { + public function __construct(string $message = 'Password confirmation is required') { + parent::__construct($message, Http::STATUS_FORBIDDEN); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/NotLoggedInException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/NotLoggedInException.php new file mode 100644 index 00000000000..e5a7853a64b --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/NotLoggedInException.php @@ -0,0 +1,22 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +use OCP\AppFramework\Http; + +/** + * Class NotLoggedInException is thrown when a resource has been requested by a + * guest user that is not accessible to the public. + * + * @package OC\AppFramework\Middleware\Security\Exceptions + */ +class NotLoggedInException extends SecurityException { + public function __construct() { + parent::__construct('Current user is not logged in', Http::STATUS_UNAUTHORIZED); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/ReloadExecutionException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/ReloadExecutionException.php new file mode 100644 index 00000000000..d12ee96292e --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/ReloadExecutionException.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +class ReloadExecutionException extends SecurityException { +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/SecurityException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/SecurityException.php new file mode 100644 index 00000000000..c8d70ad4f2b --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/SecurityException.php @@ -0,0 +1,18 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +/** + * Class SecurityException is the base class for security exceptions thrown by + * the security middleware. + * + * @package OC\AppFramework\Middleware\Security\Exceptions + */ +class SecurityException extends \Exception { +} diff --git a/lib/private/AppFramework/Middleware/Security/Exceptions/StrictCookieMissingException.php b/lib/private/AppFramework/Middleware/Security/Exceptions/StrictCookieMissingException.php new file mode 100644 index 00000000000..8ae20ab4e70 --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/Exceptions/StrictCookieMissingException.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security\Exceptions; + +use OCP\AppFramework\Http; + +/** + * Class StrictCookieMissingException is thrown when the strict cookie has not + * been sent with the request but is required. + * + * @package OC\AppFramework\Middleware\Security\Exceptions + */ +class StrictCookieMissingException extends SecurityException { + public function __construct() { + parent::__construct('Strict Cookie has not been found in request.', Http::STATUS_PRECONDITION_FAILED); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/FeaturePolicyMiddleware.php b/lib/private/AppFramework/Middleware/Security/FeaturePolicyMiddleware.php new file mode 100644 index 00000000000..921630e6326 --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/FeaturePolicyMiddleware.php @@ -0,0 +1,48 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security; + +use OC\Security\FeaturePolicy\FeaturePolicy; +use OC\Security\FeaturePolicy\FeaturePolicyManager; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\EmptyFeaturePolicy; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Middleware; + +class FeaturePolicyMiddleware extends Middleware { + /** @var FeaturePolicyManager */ + private $policyManager; + + public function __construct(FeaturePolicyManager $policyManager) { + $this->policyManager = $policyManager; + } + + /** + * Performs the default FeaturePolicy modifications that may be injected by other + * applications + * + * @param Controller $controller + * @param string $methodName + * @param Response $response + * @return Response + */ + public function afterController($controller, $methodName, Response $response): Response { + $policy = !is_null($response->getFeaturePolicy()) ? $response->getFeaturePolicy() : new FeaturePolicy(); + + if (get_class($policy) === EmptyFeaturePolicy::class) { + return $response; + } + + $defaultPolicy = $this->policyManager->getDefaultPolicy(); + $defaultPolicy = $this->policyManager->mergePolicies($defaultPolicy, $policy); + $response->setFeaturePolicy($defaultPolicy); + + return $response; + } +} diff --git a/lib/private/AppFramework/Middleware/Security/PasswordConfirmationMiddleware.php b/lib/private/AppFramework/Middleware/Security/PasswordConfirmationMiddleware.php new file mode 100644 index 00000000000..0facbffe504 --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/PasswordConfirmationMiddleware.php @@ -0,0 +1,121 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security; + +use OC\AppFramework\Middleware\Security\Exceptions\NotConfirmedException; +use OC\AppFramework\Utility\ControllerMethodReflector; +use OC\Authentication\Token\IProvider; +use OC\User\Manager; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; +use OCP\AppFramework\Middleware; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Authentication\Exceptions\ExpiredTokenException; +use OCP\Authentication\Exceptions\InvalidTokenException; +use OCP\Authentication\Exceptions\WipeTokenException; +use OCP\Authentication\Token\IToken; +use OCP\IRequest; +use OCP\ISession; +use OCP\IUserSession; +use OCP\Session\Exceptions\SessionNotAvailableException; +use OCP\User\Backend\IPasswordConfirmationBackend; +use Psr\Log\LoggerInterface; +use ReflectionMethod; + +class PasswordConfirmationMiddleware extends Middleware { + private array $excludedUserBackEnds = ['user_saml' => true, 'user_globalsiteselector' => true]; + + public function __construct( + private ControllerMethodReflector $reflector, + private ISession $session, + private IUserSession $userSession, + private ITimeFactory $timeFactory, + private IProvider $tokenProvider, + private readonly LoggerInterface $logger, + private readonly IRequest $request, + private readonly Manager $userManager, + ) { + } + + /** + * @throws NotConfirmedException + */ + public function beforeController(Controller $controller, string $methodName) { + $reflectionMethod = new ReflectionMethod($controller, $methodName); + + if (!$this->needsPasswordConfirmation($reflectionMethod)) { + return; + } + + $user = $this->userSession->getUser(); + $backendClassName = ''; + if ($user !== null) { + $backend = $user->getBackend(); + if ($backend instanceof IPasswordConfirmationBackend) { + if (!$backend->canConfirmPassword($user->getUID())) { + return; + } + } + + $backendClassName = $user->getBackendClassName(); + } + + try { + $sessionId = $this->session->getId(); + $token = $this->tokenProvider->getToken($sessionId); + } catch (SessionNotAvailableException|InvalidTokenException|WipeTokenException|ExpiredTokenException) { + // States we do not deal with here. + return; + } + + $scope = $token->getScopeAsArray(); + if (isset($scope[IToken::SCOPE_SKIP_PASSWORD_VALIDATION]) && $scope[IToken::SCOPE_SKIP_PASSWORD_VALIDATION] === true) { + // Users logging in from SSO backends cannot confirm their password by design + return; + } + + if ($this->isPasswordConfirmationStrict($reflectionMethod)) { + $authHeader = $this->request->getHeader('Authorization'); + if (!str_starts_with(strtolower($authHeader), 'basic ')) { + throw new NotConfirmedException('Required authorization header missing'); + } + [, $password] = explode(':', base64_decode(substr($authHeader, 6)), 2); + $loginName = $this->session->get('loginname'); + $loginResult = $this->userManager->checkPassword($loginName, $password); + if ($loginResult === false) { + throw new NotConfirmedException(); + } + + $this->session->set('last-password-confirm', $this->timeFactory->getTime()); + } else { + $lastConfirm = (int)$this->session->get('last-password-confirm'); + // TODO: confirm excludedUserBackEnds can go away and remove it + if (!isset($this->excludedUserBackEnds[$backendClassName]) && $lastConfirm < ($this->timeFactory->getTime() - (30 * 60 + 15))) { // allow 15 seconds delay + throw new NotConfirmedException(); + } + } + } + + private function needsPasswordConfirmation(ReflectionMethod $reflectionMethod): bool { + $attributes = $reflectionMethod->getAttributes(PasswordConfirmationRequired::class); + if (!empty($attributes)) { + return true; + } + + if ($this->reflector->hasAnnotation('PasswordConfirmationRequired')) { + $this->logger->debug($reflectionMethod->getDeclaringClass()->getName() . '::' . $reflectionMethod->getName() . ' uses the @' . 'PasswordConfirmationRequired' . ' annotation and should use the #[PasswordConfirmationRequired] attribute instead'); + return true; + } + + return false; + } + + private function isPasswordConfirmationStrict(ReflectionMethod $reflectionMethod): bool { + $attributes = $reflectionMethod->getAttributes(PasswordConfirmationRequired::class); + return !empty($attributes) && ($attributes[0]->newInstance()->getStrict()); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/RateLimitingMiddleware.php b/lib/private/AppFramework/Middleware/Security/RateLimitingMiddleware.php new file mode 100644 index 00000000000..2d19be97993 --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/RateLimitingMiddleware.php @@ -0,0 +1,162 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security; + +use OC\AppFramework\Utility\ControllerMethodReflector; +use OC\Security\Ip\BruteforceAllowList; +use OC\Security\RateLimiting\Exception\RateLimitExceededException; +use OC\Security\RateLimiting\Limiter; +use OC\User\Session; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\AnonRateLimit; +use OCP\AppFramework\Http\Attribute\ARateLimit; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Middleware; +use OCP\IAppConfig; +use OCP\IRequest; +use OCP\ISession; +use OCP\IUserSession; +use ReflectionMethod; + +/** + * Class RateLimitingMiddleware is the middleware responsible for implementing the + * ratelimiting in Nextcloud. + * + * It parses annotations such as: + * + * @UserRateThrottle(limit=5, period=100) + * @AnonRateThrottle(limit=1, period=100) + * + * Or attributes such as: + * + * #[UserRateLimit(limit: 5, period: 100)] + * #[AnonRateLimit(limit: 1, period: 100)] + * + * Both sets would mean that logged-in users can access the page 5 + * times within 100 seconds, and anonymous users 1 time within 100 seconds. If + * only an AnonRateThrottle is specified that one will also be applied to logged-in + * users. + * + * @package OC\AppFramework\Middleware\Security + */ +class RateLimitingMiddleware extends Middleware { + public function __construct( + protected IRequest $request, + protected IUserSession $userSession, + protected ControllerMethodReflector $reflector, + protected Limiter $limiter, + protected ISession $session, + protected IAppConfig $appConfig, + protected BruteforceAllowList $bruteForceAllowList, + ) { + } + + /** + * {@inheritDoc} + * @throws RateLimitExceededException + */ + public function beforeController(Controller $controller, string $methodName): void { + parent::beforeController($controller, $methodName); + $rateLimitIdentifier = get_class($controller) . '::' . $methodName; + + if ($this->userSession instanceof Session && $this->userSession->getSession()->get('app_api') === true && $this->userSession->getUser() === null) { + // if userId is not specified and the request is authenticated by AppAPI, we skip the rate limit + return; + } + + if ($this->userSession->isLoggedIn()) { + $rateLimit = $this->readLimitFromAnnotationOrAttribute($controller, $methodName, 'UserRateThrottle', UserRateLimit::class); + + if ($rateLimit !== null) { + if ($this->appConfig->getValueBool('bruteforcesettings', 'apply_allowlist_to_ratelimit') + && $this->bruteForceAllowList->isBypassListed($this->request->getRemoteAddress())) { + return; + } + + $this->limiter->registerUserRequest( + $rateLimitIdentifier, + $rateLimit->getLimit(), + $rateLimit->getPeriod(), + $this->userSession->getUser() + ); + return; + } + + // If not user specific rate limit is found the Anon rate limit applies! + } + + $rateLimit = $this->readLimitFromAnnotationOrAttribute($controller, $methodName, 'AnonRateThrottle', AnonRateLimit::class); + + if ($rateLimit !== null) { + $this->limiter->registerAnonRequest( + $rateLimitIdentifier, + $rateLimit->getLimit(), + $rateLimit->getPeriod(), + $this->request->getRemoteAddress() + ); + } + } + + /** + * @template T of ARateLimit + * + * @param Controller $controller + * @param string $methodName + * @param string $annotationName + * @param class-string<T> $attributeClass + * @return ?ARateLimit + */ + protected function readLimitFromAnnotationOrAttribute(Controller $controller, string $methodName, string $annotationName, string $attributeClass): ?ARateLimit { + $annotationLimit = $this->reflector->getAnnotationParameter($annotationName, 'limit'); + $annotationPeriod = $this->reflector->getAnnotationParameter($annotationName, 'period'); + + if ($annotationLimit !== '' && $annotationPeriod !== '') { + return new $attributeClass( + (int)$annotationLimit, + (int)$annotationPeriod, + ); + } + + $reflectionMethod = new ReflectionMethod($controller, $methodName); + $attributes = $reflectionMethod->getAttributes($attributeClass); + $attribute = current($attributes); + + if ($attribute !== false) { + return $attribute->newInstance(); + } + + return null; + } + + /** + * {@inheritDoc} + */ + public function afterException(Controller $controller, string $methodName, \Exception $exception): Response { + if ($exception instanceof RateLimitExceededException) { + if (stripos($this->request->getHeader('Accept'), 'html') === false) { + $response = new DataResponse([], $exception->getCode()); + } else { + $response = new TemplateResponse( + 'core', + '429', + [], + TemplateResponse::RENDER_AS_GUEST + ); + $response->setStatus($exception->getCode()); + } + + return $response; + } + + throw $exception; + } +} diff --git a/lib/private/AppFramework/Middleware/Security/ReloadExecutionMiddleware.php b/lib/private/AppFramework/Middleware/Security/ReloadExecutionMiddleware.php new file mode 100644 index 00000000000..e770fa4cbff --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/ReloadExecutionMiddleware.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security; + +use OC\AppFramework\Middleware\Security\Exceptions\ReloadExecutionException; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Middleware; +use OCP\ISession; +use OCP\IURLGenerator; + +/** + * Simple middleware to handle the clearing of the execution context. This will trigger + * a reload but if the session variable is set we properly redirect to the login page. + */ +class ReloadExecutionMiddleware extends Middleware { + /** @var ISession */ + private $session; + /** @var IURLGenerator */ + private $urlGenerator; + + public function __construct(ISession $session, IURLGenerator $urlGenerator) { + $this->session = $session; + $this->urlGenerator = $urlGenerator; + } + + public function beforeController($controller, $methodName) { + if ($this->session->exists('clearingExecutionContexts')) { + throw new ReloadExecutionException(); + } + } + + public function afterException($controller, $methodName, \Exception $exception) { + if ($exception instanceof ReloadExecutionException) { + $this->session->remove('clearingExecutionContexts'); + + return new RedirectResponse($this->urlGenerator->linkToRouteAbsolute( + 'core.login.showLoginForm', + ['clear' => true] // this param the code in login.js may be removed when the "Clear-Site-Data" is working in the browsers + )); + } + + return parent::afterException($controller, $methodName, $exception); + } +} diff --git a/lib/private/AppFramework/Middleware/Security/SameSiteCookieMiddleware.php b/lib/private/AppFramework/Middleware/Security/SameSiteCookieMiddleware.php new file mode 100644 index 00000000000..097ed1b2b8f --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/SameSiteCookieMiddleware.php @@ -0,0 +1,83 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\AppFramework\Middleware\Security; + +use OC\AppFramework\Http\Request; +use OC\AppFramework\Middleware\Security\Exceptions\LaxSameSiteCookieFailedException; +use OC\AppFramework\Utility\ControllerMethodReflector; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Middleware; + +class SameSiteCookieMiddleware extends Middleware { + public function __construct( + private Request $request, + private ControllerMethodReflector $reflector, + ) { + } + + public function beforeController($controller, $methodName) { + $requestUri = $this->request->getScriptName(); + $processingScript = explode('/', $requestUri); + $processingScript = $processingScript[count($processingScript) - 1]; + + if ($processingScript !== 'index.php') { + return; + } + + $noSSC = $this->reflector->hasAnnotation('NoSameSiteCookieRequired'); + if ($noSSC) { + return; + } + + if (!$this->request->passesLaxCookieCheck()) { + throw new LaxSameSiteCookieFailedException(); + } + } + + public function afterException($controller, $methodName, \Exception $exception) { + if ($exception instanceof LaxSameSiteCookieFailedException) { + $response = new Response(); + $response->setStatus(Http::STATUS_FOUND); + $response->addHeader('Location', $this->request->getRequestUri()); + + $this->setSameSiteCookie(); + + return $response; + } + + throw $exception; + } + + protected function setSameSiteCookie(): void { + $cookieParams = $this->request->getCookieParams(); + $secureCookie = ($cookieParams['secure'] === true) ? 'secure; ' : ''; + $policies = [ + 'lax', + 'strict', + ]; + + // Append __Host to the cookie if it meets the requirements + $cookiePrefix = ''; + if ($cookieParams['secure'] === true && $cookieParams['path'] === '/') { + $cookiePrefix = '__Host-'; + } + + foreach ($policies as $policy) { + header( + sprintf( + 'Set-Cookie: %snc_sameSiteCookie%s=true; path=%s; httponly;' . $secureCookie . 'expires=Fri, 31-Dec-2100 23:59:59 GMT; SameSite=%s', + $cookiePrefix, + $policy, + $cookieParams['path'], + $policy + ), + false + ); + } + } +} diff --git a/lib/private/AppFramework/Middleware/Security/SecurityMiddleware.php b/lib/private/AppFramework/Middleware/Security/SecurityMiddleware.php new file mode 100644 index 00000000000..e3a293e0fd9 --- /dev/null +++ b/lib/private/AppFramework/Middleware/Security/SecurityMiddleware.php @@ -0,0 +1,332 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\AppFramework\Middleware\Security; + +use OC\AppFramework\Middleware\Security\Exceptions\AdminIpNotAllowedException; +use OC\AppFramework\Middleware\Security\Exceptions\AppNotEnabledException; +use OC\AppFramework\Middleware\Security\Exceptions\CrossSiteRequestForgeryException; +use OC\AppFramework\Middleware\Security\Exceptions\ExAppRequiredException; +use OC\AppFramework\Middleware\Security\Exceptions\NotAdminException; +use OC\AppFramework\Middleware\Security\Exceptions\NotLoggedInException; +use OC\AppFramework\Middleware\Security\Exceptions\SecurityException; +use OC\AppFramework\Middleware\Security\Exceptions\StrictCookieMissingException; +use OC\AppFramework\Utility\ControllerMethodReflector; +use OC\Settings\AuthorizedGroupMapper; +use OC\User\Session; +use OCP\App\AppPathNotFoundException; +use OCP\App\IAppManager; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\AppApiAdminAccessWithoutUser; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\Attribute\ExAppRequired; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\Attribute\StrictCookiesRequired; +use OCP\AppFramework\Http\Attribute\SubAdminRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Middleware; +use OCP\AppFramework\OCSController; +use OCP\Group\ISubAdmin; +use OCP\IGroupManager; +use OCP\IL10N; +use OCP\INavigationManager; +use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\IUserSession; +use OCP\Security\Ip\IRemoteAddress; +use OCP\Util; +use Psr\Log\LoggerInterface; +use ReflectionMethod; + +/** + * Used to do all the authentication and checking stuff for a controller method + * It reads out the annotations of a controller method and checks which if + * security things should be checked and also handles errors in case a security + * check fails + */ +class SecurityMiddleware extends Middleware { + private ?bool $isAdminUser = null; + private ?bool $isSubAdmin = null; + + public function __construct( + private IRequest $request, + private ControllerMethodReflector $reflector, + private INavigationManager $navigationManager, + private IURLGenerator $urlGenerator, + private LoggerInterface $logger, + private string $appName, + private bool $isLoggedIn, + private IGroupManager $groupManager, + private ISubAdmin $subAdminManager, + private IAppManager $appManager, + private IL10N $l10n, + private AuthorizedGroupMapper $groupAuthorizationMapper, + private IUserSession $userSession, + private IRemoteAddress $remoteAddress, + ) { + } + + private function isAdminUser(): bool { + if ($this->isAdminUser === null) { + $user = $this->userSession->getUser(); + $this->isAdminUser = $user && $this->groupManager->isAdmin($user->getUID()); + } + return $this->isAdminUser; + } + + private function isSubAdmin(): bool { + if ($this->isSubAdmin === null) { + $user = $this->userSession->getUser(); + $this->isSubAdmin = $user && $this->subAdminManager->isSubAdmin($user); + } + return $this->isSubAdmin; + } + + /** + * This runs all the security checks before a method call. The + * security checks are determined by inspecting the controller method + * annotations + * + * @param Controller $controller the controller + * @param string $methodName the name of the method + * @throws SecurityException when a security check fails + * + * @suppress PhanUndeclaredClassConstant + */ + public function beforeController($controller, $methodName) { + // this will set the current navigation entry of the app, use this only + // for normal HTML requests and not for AJAX requests + $this->navigationManager->setActiveEntry($this->appName); + + if (get_class($controller) === \OCA\Talk\Controller\PageController::class && $methodName === 'showCall') { + $this->navigationManager->setActiveEntry('spreed'); + } + + $reflectionMethod = new ReflectionMethod($controller, $methodName); + + // security checks + $isPublicPage = $this->hasAnnotationOrAttribute($reflectionMethod, 'PublicPage', PublicPage::class); + + if ($this->hasAnnotationOrAttribute($reflectionMethod, 'ExAppRequired', ExAppRequired::class)) { + if (!$this->userSession instanceof Session || $this->userSession->getSession()->get('app_api') !== true) { + throw new ExAppRequiredException(); + } + } elseif (!$isPublicPage) { + $authorized = false; + if ($this->hasAnnotationOrAttribute($reflectionMethod, null, AppApiAdminAccessWithoutUser::class)) { + // this attribute allows ExApp to access admin endpoints only if "userId" is "null" + if ($this->userSession instanceof Session && $this->userSession->getSession()->get('app_api') === true && $this->userSession->getUser() === null) { + $authorized = true; + } + } + + if (!$authorized && !$this->isLoggedIn) { + throw new NotLoggedInException(); + } + + if (!$authorized && $this->hasAnnotationOrAttribute($reflectionMethod, 'AuthorizedAdminSetting', AuthorizedAdminSetting::class)) { + $authorized = $this->isAdminUser(); + + if (!$authorized && $this->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class)) { + $authorized = $this->isSubAdmin(); + } + + if (!$authorized) { + $settingClasses = $this->getAuthorizedAdminSettingClasses($reflectionMethod); + $authorizedClasses = $this->groupAuthorizationMapper->findAllClassesForUser($this->userSession->getUser()); + foreach ($settingClasses as $settingClass) { + $authorized = in_array($settingClass, $authorizedClasses, true); + + if ($authorized) { + break; + } + } + } + if (!$authorized) { + throw new NotAdminException($this->l10n->t('Logged in account must be an admin, a sub admin or gotten special right to access this setting')); + } + if (!$this->remoteAddress->allowsAdminActions()) { + throw new AdminIpNotAllowedException($this->l10n->t('Your current IP address doesn\'t allow you to perform admin actions')); + } + } + if ($this->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class) + && !$this->isSubAdmin() + && !$this->isAdminUser() + && !$authorized) { + throw new NotAdminException($this->l10n->t('Logged in account must be an admin or sub admin')); + } + if (!$this->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class) + && !$this->hasAnnotationOrAttribute($reflectionMethod, 'NoAdminRequired', NoAdminRequired::class) + && !$this->isAdminUser() + && !$authorized) { + throw new NotAdminException($this->l10n->t('Logged in account must be an admin')); + } + if ($this->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class) + && !$this->remoteAddress->allowsAdminActions()) { + throw new AdminIpNotAllowedException($this->l10n->t('Your current IP address doesn\'t allow you to perform admin actions')); + } + if (!$this->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class) + && !$this->hasAnnotationOrAttribute($reflectionMethod, 'NoAdminRequired', NoAdminRequired::class) + && !$this->remoteAddress->allowsAdminActions()) { + throw new AdminIpNotAllowedException($this->l10n->t('Your current IP address doesn\'t allow you to perform admin actions')); + } + + } + + // Check for strict cookie requirement + if ($this->hasAnnotationOrAttribute($reflectionMethod, 'StrictCookieRequired', StrictCookiesRequired::class) + || !$this->hasAnnotationOrAttribute($reflectionMethod, 'NoCSRFRequired', NoCSRFRequired::class)) { + if (!$this->request->passesStrictCookieCheck()) { + throw new StrictCookieMissingException(); + } + } + // CSRF check - also registers the CSRF token since the session may be closed later + Util::callRegister(); + if ($this->isInvalidCSRFRequired($reflectionMethod)) { + /* + * Only allow the CSRF check to fail on OCS Requests. This kind of + * hacks around that we have no full token auth in place yet and we + * do want to offer CSRF checks for web requests. + * + * Additionally we allow Bearer authenticated requests to pass on OCS routes. + * This allows oauth apps (e.g. moodle) to use the OCS endpoints + */ + if (!$controller instanceof OCSController || !$this->isValidOCSRequest()) { + throw new CrossSiteRequestForgeryException(); + } + } + + /** + * Checks if app is enabled (also includes a check whether user is allowed to access the resource) + * The getAppPath() check is here since components such as settings also use the AppFramework and + * therefore won't pass this check. + * If page is public, app does not need to be enabled for current user/visitor + */ + try { + $appPath = $this->appManager->getAppPath($this->appName); + } catch (AppPathNotFoundException $e) { + $appPath = false; + } + + if ($appPath !== false && !$isPublicPage && !$this->appManager->isEnabledForUser($this->appName)) { + throw new AppNotEnabledException(); + } + } + + private function isInvalidCSRFRequired(ReflectionMethod $reflectionMethod): bool { + if ($this->hasAnnotationOrAttribute($reflectionMethod, 'NoCSRFRequired', NoCSRFRequired::class)) { + return false; + } + + return !$this->request->passesCSRFCheck(); + } + + private function isValidOCSRequest(): bool { + return $this->request->getHeader('OCS-APIREQUEST') === 'true' + || str_starts_with($this->request->getHeader('Authorization'), 'Bearer '); + } + + /** + * @template T + * + * @param ReflectionMethod $reflectionMethod + * @param ?string $annotationName + * @param class-string<T> $attributeClass + * @return boolean + */ + protected function hasAnnotationOrAttribute(ReflectionMethod $reflectionMethod, ?string $annotationName, string $attributeClass): bool { + if (!empty($reflectionMethod->getAttributes($attributeClass))) { + return true; + } + + if ($annotationName && $this->reflector->hasAnnotation($annotationName)) { + $this->logger->debug($reflectionMethod->getDeclaringClass()->getName() . '::' . $reflectionMethod->getName() . ' uses the @' . $annotationName . ' annotation and should use the #[' . $attributeClass . '] attribute instead'); + return true; + } + + return false; + } + + /** + * @param ReflectionMethod $reflectionMethod + * @return string[] + */ + protected function getAuthorizedAdminSettingClasses(ReflectionMethod $reflectionMethod): array { + $classes = []; + if ($this->reflector->hasAnnotation('AuthorizedAdminSetting')) { + $classes = explode(';', $this->reflector->getAnnotationParameter('AuthorizedAdminSetting', 'settings')); + } + + $attributes = $reflectionMethod->getAttributes(AuthorizedAdminSetting::class); + if (!empty($attributes)) { + foreach ($attributes as $attribute) { + /** @var AuthorizedAdminSetting $setting */ + $setting = $attribute->newInstance(); + $classes[] = $setting->getSettings(); + } + } + + return $classes; + } + + /** + * If an SecurityException is being caught, ajax requests return a JSON error + * response and non ajax requests redirect to the index + * + * @param Controller $controller the controller that is being called + * @param string $methodName the name of the method that will be called on + * the controller + * @param \Exception $exception the thrown exception + * @return Response a Response object or null in case that the exception could not be handled + * @throws \Exception the passed in exception if it can't handle it + */ + public function afterException($controller, $methodName, \Exception $exception): Response { + if ($exception instanceof SecurityException) { + if ($exception instanceof StrictCookieMissingException) { + return new RedirectResponse(\OC::$WEBROOT . '/'); + } + if (stripos($this->request->getHeader('Accept'), 'html') === false) { + $response = new JSONResponse( + ['message' => $exception->getMessage()], + $exception->getCode() + ); + } else { + if ($exception instanceof NotLoggedInException) { + $params = []; + if (isset($this->request->server['REQUEST_URI'])) { + $params['redirect_url'] = $this->request->server['REQUEST_URI']; + } + $usernamePrefill = $this->request->getParam('user', ''); + if ($usernamePrefill !== '') { + $params['user'] = $usernamePrefill; + } + if ($this->request->getParam('direct')) { + $params['direct'] = 1; + } + $url = $this->urlGenerator->linkToRoute('core.login.showLoginForm', $params); + $response = new RedirectResponse($url); + } else { + $response = new TemplateResponse('core', '403', ['message' => $exception->getMessage()], 'guest'); + $response->setStatus($exception->getCode()); + } + } + + $this->logger->debug($exception->getMessage(), [ + 'exception' => $exception, + ]); + return $response; + } + + throw $exception; + } +} |