aboutsummaryrefslogtreecommitdiffstats
path: root/lib/private/AppFramework/Middleware/Security
diff options
context:
space:
mode:
Diffstat (limited to 'lib/private/AppFramework/Middleware/Security')
-rw-r--r--lib/private/AppFramework/Middleware/Security/BruteForceMiddleware.php137
-rw-r--r--lib/private/AppFramework/Middleware/Security/CORSMiddleware.php175
-rw-r--r--lib/private/AppFramework/Middleware/Security/CSPMiddleware.php54
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/AdminIpNotAllowedException.php23
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/AppNotEnabledException.php22
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/CrossSiteRequestForgeryException.php22
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/ExAppRequiredException.php18
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/LaxSameSiteCookieFailedException.php21
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/NotAdminException.php23
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/NotConfirmedException.php21
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/NotLoggedInException.php22
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/ReloadExecutionException.php12
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/SecurityException.php18
-rw-r--r--lib/private/AppFramework/Middleware/Security/Exceptions/StrictCookieMissingException.php23
-rw-r--r--lib/private/AppFramework/Middleware/Security/FeaturePolicyMiddleware.php48
-rw-r--r--lib/private/AppFramework/Middleware/Security/PasswordConfirmationMiddleware.php121
-rw-r--r--lib/private/AppFramework/Middleware/Security/RateLimitingMiddleware.php162
-rw-r--r--lib/private/AppFramework/Middleware/Security/ReloadExecutionMiddleware.php50
-rw-r--r--lib/private/AppFramework/Middleware/Security/SameSiteCookieMiddleware.php83
-rw-r--r--lib/private/AppFramework/Middleware/Security/SecurityMiddleware.php332
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;
+ }
+}