diff options
Diffstat (limited to 'tests/lib/AppFramework/Middleware/Security')
13 files changed, 2601 insertions, 0 deletions
diff --git a/tests/lib/AppFramework/Middleware/Security/BruteForceMiddlewareTest.php b/tests/lib/AppFramework/Middleware/Security/BruteForceMiddlewareTest.php new file mode 100644 index 00000000000..3fd2cb38a33 --- /dev/null +++ b/tests/lib/AppFramework/Middleware/Security/BruteForceMiddlewareTest.php @@ -0,0 +1,328 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace Test\AppFramework\Middleware\Security; + +use OC\AppFramework\Middleware\Security\BruteForceMiddleware; +use OC\AppFramework\Utility\ControllerMethodReflector; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\BruteForceProtection; +use OCP\AppFramework\Http\Response; +use OCP\IRequest; +use OCP\Security\Bruteforce\IThrottler; +use Psr\Log\LoggerInterface; +use Test\TestCase; + +class TestController extends Controller { + /** + * @BruteForceProtection(action=login) + */ + public function testMethodWithAnnotation() { + } + + public function testMethodWithoutAnnotation() { + } + + #[BruteForceProtection(action: 'single')] + public function singleAttribute(): void { + } + + #[BruteForceProtection(action: 'first')] + #[BruteForceProtection(action: 'second')] + public function multipleAttributes(): void { + } +} + +class BruteForceMiddlewareTest extends TestCase { + /** @var ControllerMethodReflector */ + private $reflector; + /** @var IThrottler|\PHPUnit\Framework\MockObject\MockObject */ + private $throttler; + /** @var IRequest|\PHPUnit\Framework\MockObject\MockObject */ + private $request; + /** @var LoggerInterface|\PHPUnit\Framework\MockObject\MockObject */ + private $logger; + private BruteForceMiddleware $bruteForceMiddleware; + + protected function setUp(): void { + parent::setUp(); + + $this->reflector = new ControllerMethodReflector(); + $this->throttler = $this->createMock(IThrottler::class); + $this->request = $this->createMock(IRequest::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->bruteForceMiddleware = new BruteForceMiddleware( + $this->reflector, + $this->throttler, + $this->request, + $this->logger, + ); + } + + public function testBeforeControllerWithAnnotation(): void { + $this->request + ->expects($this->once()) + ->method('getRemoteAddress') + ->willReturn('127.0.0.1'); + $this->throttler + ->expects($this->once()) + ->method('sleepDelayOrThrowOnMax') + ->with('127.0.0.1', 'login'); + + $controller = new TestController('test', $this->request); + $this->reflector->reflect($controller, 'testMethodWithAnnotation'); + $this->bruteForceMiddleware->beforeController($controller, 'testMethodWithAnnotation'); + } + + public function testBeforeControllerWithSingleAttribute(): void { + $this->request + ->expects($this->once()) + ->method('getRemoteAddress') + ->willReturn('::1'); + $this->throttler + ->expects($this->once()) + ->method('sleepDelayOrThrowOnMax') + ->with('::1', 'single'); + + $controller = new TestController('test', $this->request); + $this->reflector->reflect($controller, 'singleAttribute'); + $this->bruteForceMiddleware->beforeController($controller, 'singleAttribute'); + } + + public function testBeforeControllerWithMultipleAttributes(): void { + $this->request + ->expects($this->once()) + ->method('getRemoteAddress') + ->willReturn('::1'); + + $calls = [ + ['::1', 'first'], + ['::1', 'second'], + ]; + $this->throttler + ->expects($this->exactly(2)) + ->method('sleepDelayOrThrowOnMax') + ->willReturnCallback(function () use (&$calls) { + $expected = array_shift($calls); + $this->assertEquals($expected, func_get_args()); + return 0; + }); + + $controller = new TestController('test', $this->request); + $this->reflector->reflect($controller, 'multipleAttributes'); + $this->bruteForceMiddleware->beforeController($controller, 'multipleAttributes'); + } + + public function testBeforeControllerWithoutAnnotation(): void { + $this->request + ->expects($this->never()) + ->method('getRemoteAddress'); + $this->throttler + ->expects($this->never()) + ->method('sleepDelayOrThrowOnMax'); + + $controller = new TestController('test', $this->request); + $this->reflector->reflect($controller, 'testMethodWithoutAnnotation'); + $this->bruteForceMiddleware->beforeController($controller, 'testMethodWithoutAnnotation'); + } + + public function testAfterControllerWithAnnotationAndThrottledRequest(): void { + /** @var Response|\PHPUnit\Framework\MockObject\MockObject $response */ + $response = $this->createMock(Response::class); + $response + ->expects($this->once()) + ->method('isThrottled') + ->willReturn(true); + $response + ->expects($this->once()) + ->method('getThrottleMetadata') + ->willReturn([]); + $this->request + ->expects($this->once()) + ->method('getRemoteAddress') + ->willReturn('127.0.0.1'); + $this->throttler + ->expects($this->once()) + ->method('sleepDelayOrThrowOnMax') + ->with('127.0.0.1', 'login'); + $this->throttler + ->expects($this->once()) + ->method('registerAttempt') + ->with('login', '127.0.0.1'); + + $controller = new TestController('test', $this->request); + $this->reflector->reflect($controller, 'testMethodWithAnnotation'); + $this->bruteForceMiddleware->afterController($controller, 'testMethodWithAnnotation', $response); + } + + public function testAfterControllerWithAnnotationAndNotThrottledRequest(): void { + /** @var Response|\PHPUnit\Framework\MockObject\MockObject $response */ + $response = $this->createMock(Response::class); + $response + ->expects($this->once()) + ->method('isThrottled') + ->willReturn(false); + $this->request + ->expects($this->never()) + ->method('getRemoteAddress'); + $this->throttler + ->expects($this->never()) + ->method('sleepDelayOrThrowOnMax'); + $this->throttler + ->expects($this->never()) + ->method('registerAttempt'); + + $controller = new TestController('test', $this->request); + $this->reflector->reflect($controller, 'testMethodWithAnnotation'); + $this->bruteForceMiddleware->afterController($controller, 'testMethodWithAnnotation', $response); + } + + public function testAfterControllerWithSingleAttribute(): void { + /** @var Response|\PHPUnit\Framework\MockObject\MockObject $response */ + $response = $this->createMock(Response::class); + $response + ->expects($this->once()) + ->method('isThrottled') + ->willReturn(true); + $response + ->expects($this->once()) + ->method('getThrottleMetadata') + ->willReturn([]); + + $this->request + ->expects($this->once()) + ->method('getRemoteAddress') + ->willReturn('::1'); + $this->throttler + ->expects($this->once()) + ->method('sleepDelayOrThrowOnMax') + ->with('::1', 'single'); + $this->throttler + ->expects($this->once()) + ->method('registerAttempt') + ->with('single', '::1'); + + $controller = new TestController('test', $this->request); + $this->reflector->reflect($controller, 'singleAttribute'); + $this->bruteForceMiddleware->afterController($controller, 'singleAttribute', $response); + } + + public function testAfterControllerWithMultipleAttributesGeneralMatch(): void { + /** @var Response|\PHPUnit\Framework\MockObject\MockObject $response */ + $response = $this->createMock(Response::class); + $response + ->expects($this->once()) + ->method('isThrottled') + ->willReturn(true); + $response + ->expects($this->once()) + ->method('getThrottleMetadata') + ->willReturn([]); + + $this->request + ->expects($this->once()) + ->method('getRemoteAddress') + ->willReturn('::1'); + + $sleepCalls = [ + ['::1', 'first'], + ['::1', 'second'], + ]; + $this->throttler + ->expects($this->exactly(2)) + ->method('sleepDelayOrThrowOnMax') + ->willReturnCallback(function () use (&$sleepCalls) { + $expected = array_shift($sleepCalls); + $this->assertEquals($expected, func_get_args()); + return 0; + }); + + $attemptCalls = [ + ['first', '::1', []], + ['second', '::1', []], + ]; + $this->throttler + ->expects($this->exactly(2)) + ->method('registerAttempt') + ->willReturnCallback(function () use (&$attemptCalls): void { + $expected = array_shift($attemptCalls); + $this->assertEquals($expected, func_get_args()); + }); + + $controller = new TestController('test', $this->request); + $this->reflector->reflect($controller, 'multipleAttributes'); + $this->bruteForceMiddleware->afterController($controller, 'multipleAttributes', $response); + } + + public function testAfterControllerWithMultipleAttributesSpecificMatch(): void { + /** @var Response|\PHPUnit\Framework\MockObject\MockObject $response */ + $response = $this->createMock(Response::class); + $response + ->expects($this->once()) + ->method('isThrottled') + ->willReturn(true); + $response + ->expects($this->once()) + ->method('getThrottleMetadata') + ->willReturn(['action' => 'second']); + + $this->request + ->expects($this->once()) + ->method('getRemoteAddress') + ->willReturn('::1'); + $this->throttler + ->expects($this->once()) + ->method('sleepDelayOrThrowOnMax') + ->with('::1', 'second'); + $this->throttler + ->expects($this->once()) + ->method('registerAttempt') + ->with('second', '::1'); + + $controller = new TestController('test', $this->request); + $this->reflector->reflect($controller, 'multipleAttributes'); + $this->bruteForceMiddleware->afterController($controller, 'multipleAttributes', $response); + } + + public function testAfterControllerWithoutAnnotation(): void { + $this->request + ->expects($this->never()) + ->method('getRemoteAddress'); + $this->throttler + ->expects($this->never()) + ->method('sleepDelayOrThrowOnMax'); + + $controller = new TestController('test', $this->request); + $this->reflector->reflect($controller, 'testMethodWithoutAnnotation'); + /** @var Response|\PHPUnit\Framework\MockObject\MockObject $response */ + $response = $this->createMock(Response::class); + $this->bruteForceMiddleware->afterController($controller, 'testMethodWithoutAnnotation', $response); + } + + public function testAfterControllerWithThrottledResponseButUnhandled(): void { + $this->request + ->expects($this->never()) + ->method('getRemoteAddress'); + $this->throttler + ->expects($this->never()) + ->method('sleepDelayOrThrowOnMax'); + + $controller = new TestController('test', $this->request); + $this->reflector->reflect($controller, 'testMethodWithoutAnnotation'); + /** @var Response|\PHPUnit\Framework\MockObject\MockObject $response */ + $response = $this->createMock(Response::class); + $response->method('isThrottled') + ->willReturn(true); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('Response for Test\AppFramework\Middleware\Security\TestController::testMethodWithoutAnnotation got bruteforce throttled but has no annotation nor attribute defined.'); + + $this->bruteForceMiddleware->afterController($controller, 'testMethodWithoutAnnotation', $response); + } +} diff --git a/tests/lib/AppFramework/Middleware/Security/CORSMiddlewareTest.php b/tests/lib/AppFramework/Middleware/Security/CORSMiddlewareTest.php new file mode 100644 index 00000000000..c325ae638fb --- /dev/null +++ b/tests/lib/AppFramework/Middleware/Security/CORSMiddlewareTest.php @@ -0,0 +1,344 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2014-2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace Test\AppFramework\Middleware\Security; + +use OC\AppFramework\Http\Request; +use OC\AppFramework\Middleware\Security\CORSMiddleware; +use OC\AppFramework\Middleware\Security\Exceptions\SecurityException; +use OC\AppFramework\Utility\ControllerMethodReflector; +use OC\Authentication\Exceptions\PasswordLoginForbiddenException; +use OC\User\Session; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\Response; +use OCP\IConfig; +use OCP\IRequest; +use OCP\IRequestId; +use OCP\Security\Bruteforce\IThrottler; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; +use Test\AppFramework\Middleware\Security\Mock\CORSMiddlewareController; + +class CORSMiddlewareTest extends \Test\TestCase { + /** @var ControllerMethodReflector */ + private $reflector; + /** @var Session|MockObject */ + private $session; + /** @var IThrottler|MockObject */ + private $throttler; + /** @var CORSMiddlewareController */ + private $controller; + private LoggerInterface $logger; + + protected function setUp(): void { + parent::setUp(); + $this->reflector = new ControllerMethodReflector(); + $this->session = $this->createMock(Session::class); + $this->throttler = $this->createMock(IThrottler::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->controller = new CORSMiddlewareController( + 'test', + $this->createMock(IRequest::class) + ); + } + + public static function dataSetCORSAPIHeader(): array { + return [ + ['testSetCORSAPIHeader'], + ['testSetCORSAPIHeaderAttribute'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataSetCORSAPIHeader')] + public function testSetCORSAPIHeader(string $method): void { + $request = new Request( + [ + 'server' => [ + 'HTTP_ORIGIN' => 'test' + ] + ], + $this->createMock(IRequestId::class), + $this->createMock(IConfig::class) + ); + $this->reflector->reflect($this->controller, $method); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->logger); + + $response = $middleware->afterController($this->controller, $method, new Response()); + $headers = $response->getHeaders(); + $this->assertEquals('test', $headers['Access-Control-Allow-Origin']); + } + + public function testNoAnnotationNoCORSHEADER(): void { + $request = new Request( + [ + 'server' => [ + 'HTTP_ORIGIN' => 'test' + ] + ], + $this->createMock(IRequestId::class), + $this->createMock(IConfig::class) + ); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->logger); + + $response = $middleware->afterController($this->controller, __FUNCTION__, new Response()); + $headers = $response->getHeaders(); + $this->assertFalse(array_key_exists('Access-Control-Allow-Origin', $headers)); + } + + public static function dataNoOriginHeaderNoCORSHEADER(): array { + return [ + ['testNoOriginHeaderNoCORSHEADER'], + ['testNoOriginHeaderNoCORSHEADERAttribute'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoOriginHeaderNoCORSHEADER')] + public function testNoOriginHeaderNoCORSHEADER(string $method): void { + $request = new Request( + [], + $this->createMock(IRequestId::class), + $this->createMock(IConfig::class) + ); + $this->reflector->reflect($this->controller, $method); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->logger); + + $response = $middleware->afterController($this->controller, $method, new Response()); + $headers = $response->getHeaders(); + $this->assertFalse(array_key_exists('Access-Control-Allow-Origin', $headers)); + } + + public static function dataCorsIgnoredIfWithCredentialsHeaderPresent(): array { + return [ + ['testCorsIgnoredIfWithCredentialsHeaderPresent'], + ['testCorsAttributeIgnoredIfWithCredentialsHeaderPresent'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataCorsIgnoredIfWithCredentialsHeaderPresent')] + public function testCorsIgnoredIfWithCredentialsHeaderPresent(string $method): void { + $this->expectException(SecurityException::class); + + $request = new Request( + [ + 'server' => [ + 'HTTP_ORIGIN' => 'test' + ] + ], + $this->createMock(IRequestId::class), + $this->createMock(IConfig::class) + ); + $this->reflector->reflect($this->controller, $method); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->logger); + + $response = new Response(); + $response->addHeader('AcCess-control-Allow-Credentials ', 'TRUE'); + $middleware->afterController($this->controller, $method, $response); + } + + public static function dataNoCORSOnAnonymousPublicPage(): array { + return [ + ['testNoCORSOnAnonymousPublicPage'], + ['testNoCORSOnAnonymousPublicPageAttribute'], + ['testNoCORSAttributeOnAnonymousPublicPage'], + ['testNoCORSAttributeOnAnonymousPublicPageAttribute'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCORSOnAnonymousPublicPage')] + public function testNoCORSOnAnonymousPublicPage(string $method): void { + $request = new Request( + [], + $this->createMock(IRequestId::class), + $this->createMock(IConfig::class) + ); + $this->reflector->reflect($this->controller, $method); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->logger); + $this->session->expects($this->once()) + ->method('isLoggedIn') + ->willReturn(false); + $this->session->expects($this->never()) + ->method('logout'); + $this->session->expects($this->never()) + ->method('logClientIn') + ->with($this->equalTo('user'), $this->equalTo('pass')) + ->willReturn(true); + $this->reflector->reflect($this->controller, $method); + + $middleware->beforeController($this->controller, $method); + } + + public static function dataCORSShouldNeverAllowCookieAuth(): array { + return [ + ['testCORSShouldNeverAllowCookieAuth'], + ['testCORSShouldNeverAllowCookieAuthAttribute'], + ['testCORSAttributeShouldNeverAllowCookieAuth'], + ['testCORSAttributeShouldNeverAllowCookieAuthAttribute'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataCORSShouldNeverAllowCookieAuth')] + public function testCORSShouldNeverAllowCookieAuth(string $method): void { + $request = new Request( + [], + $this->createMock(IRequestId::class), + $this->createMock(IConfig::class) + ); + $this->reflector->reflect($this->controller, $method); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->logger); + $this->session->expects($this->once()) + ->method('isLoggedIn') + ->willReturn(true); + $this->session->expects($this->once()) + ->method('logout'); + $this->session->expects($this->never()) + ->method('logClientIn') + ->with($this->equalTo('user'), $this->equalTo('pass')) + ->willReturn(true); + + $this->expectException(SecurityException::class); + $middleware->beforeController($this->controller, $method); + } + + public static function dataCORSShouldRelogin(): array { + return [ + ['testCORSShouldRelogin'], + ['testCORSAttributeShouldRelogin'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataCORSShouldRelogin')] + public function testCORSShouldRelogin(string $method): void { + $request = new Request( + ['server' => [ + 'PHP_AUTH_USER' => 'user', + 'PHP_AUTH_PW' => 'pass' + ]], + $this->createMock(IRequestId::class), + $this->createMock(IConfig::class) + ); + $this->session->expects($this->once()) + ->method('logout'); + $this->session->expects($this->once()) + ->method('logClientIn') + ->with($this->equalTo('user'), $this->equalTo('pass')) + ->willReturn(true); + $this->reflector->reflect($this->controller, $method); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->logger); + + $middleware->beforeController($this->controller, $method); + } + + public static function dataCORSShouldFailIfPasswordLoginIsForbidden(): array { + return [ + ['testCORSShouldFailIfPasswordLoginIsForbidden'], + ['testCORSAttributeShouldFailIfPasswordLoginIsForbidden'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataCORSShouldFailIfPasswordLoginIsForbidden')] + public function testCORSShouldFailIfPasswordLoginIsForbidden(string $method): void { + $this->expectException(SecurityException::class); + + $request = new Request( + ['server' => [ + 'PHP_AUTH_USER' => 'user', + 'PHP_AUTH_PW' => 'pass' + ]], + $this->createMock(IRequestId::class), + $this->createMock(IConfig::class) + ); + $this->session->expects($this->once()) + ->method('logout'); + $this->session->expects($this->once()) + ->method('logClientIn') + ->with($this->equalTo('user'), $this->equalTo('pass')) + ->willThrowException(new PasswordLoginForbiddenException); + $this->reflector->reflect($this->controller, $method); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->logger); + + $middleware->beforeController($this->controller, $method); + } + + public static function dataCORSShouldNotAllowCookieAuth(): array { + return [ + ['testCORSShouldNotAllowCookieAuth'], + ['testCORSAttributeShouldNotAllowCookieAuth'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataCORSShouldNotAllowCookieAuth')] + public function testCORSShouldNotAllowCookieAuth(string $method): void { + $this->expectException(SecurityException::class); + + $request = new Request( + ['server' => [ + 'PHP_AUTH_USER' => 'user', + 'PHP_AUTH_PW' => 'pass' + ]], + $this->createMock(IRequestId::class), + $this->createMock(IConfig::class) + ); + $this->session->expects($this->once()) + ->method('logout'); + $this->session->expects($this->once()) + ->method('logClientIn') + ->with($this->equalTo('user'), $this->equalTo('pass')) + ->willReturn(false); + $this->reflector->reflect($this->controller, $method); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->logger); + + $middleware->beforeController($this->controller, $method); + } + + public function testAfterExceptionWithSecurityExceptionNoStatus(): void { + $request = new Request( + ['server' => [ + 'PHP_AUTH_USER' => 'user', + 'PHP_AUTH_PW' => 'pass' + ]], + $this->createMock(IRequestId::class), + $this->createMock(IConfig::class) + ); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->logger); + $response = $middleware->afterException($this->controller, __FUNCTION__, new SecurityException('A security exception')); + + $expected = new JSONResponse(['message' => 'A security exception'], 500); + $this->assertEquals($expected, $response); + } + + public function testAfterExceptionWithSecurityExceptionWithStatus(): void { + $request = new Request( + ['server' => [ + 'PHP_AUTH_USER' => 'user', + 'PHP_AUTH_PW' => 'pass' + ]], + $this->createMock(IRequestId::class), + $this->createMock(IConfig::class) + ); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->logger); + $response = $middleware->afterException($this->controller, __FUNCTION__, new SecurityException('A security exception', 501)); + + $expected = new JSONResponse(['message' => 'A security exception'], 501); + $this->assertEquals($expected, $response); + } + + public function testAfterExceptionWithRegularException(): void { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('A regular exception'); + + $request = new Request( + ['server' => [ + 'PHP_AUTH_USER' => 'user', + 'PHP_AUTH_PW' => 'pass' + ]], + $this->createMock(IRequestId::class), + $this->createMock(IConfig::class) + ); + $middleware = new CORSMiddleware($request, $this->reflector, $this->session, $this->throttler, $this->logger); + $middleware->afterException($this->controller, __FUNCTION__, new \Exception('A regular exception')); + } +} diff --git a/tests/lib/AppFramework/Middleware/Security/CSPMiddlewareTest.php b/tests/lib/AppFramework/Middleware/Security/CSPMiddlewareTest.php new file mode 100644 index 00000000000..b0b41b27cb9 --- /dev/null +++ b/tests/lib/AppFramework/Middleware/Security/CSPMiddlewareTest.php @@ -0,0 +1,122 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace Test\AppFramework\Middleware\Security; + +use OC\AppFramework\Middleware\Security\CSPMiddleware; +use OC\Security\CSP\ContentSecurityPolicy; +use OC\Security\CSP\ContentSecurityPolicyManager; +use OC\Security\CSP\ContentSecurityPolicyNonceManager; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\EmptyContentSecurityPolicy; +use OCP\AppFramework\Http\Response; +use PHPUnit\Framework\MockObject\MockObject; + +class CSPMiddlewareTest extends \Test\TestCase { + /** @var CSPMiddleware&MockObject */ + private $middleware; + /** @var Controller&MockObject */ + private $controller; + /** @var ContentSecurityPolicyManager&MockObject */ + private $contentSecurityPolicyManager; + /** @var ContentSecurityPolicyNonceManager&MockObject */ + private $cspNonceManager; + + protected function setUp(): void { + parent::setUp(); + + $this->controller = $this->createMock(Controller::class); + $this->contentSecurityPolicyManager = $this->createMock(ContentSecurityPolicyManager::class); + $this->cspNonceManager = $this->createMock(ContentSecurityPolicyNonceManager::class); + $this->middleware = new CSPMiddleware( + $this->contentSecurityPolicyManager, + $this->cspNonceManager, + ); + } + + public function testAfterController(): void { + $this->cspNonceManager + ->expects($this->once()) + ->method('browserSupportsCspV3') + ->willReturn(false); + $response = $this->createMock(Response::class); + $defaultPolicy = new ContentSecurityPolicy(); + $defaultPolicy->addAllowedImageDomain('defaultpolicy'); + $currentPolicy = new ContentSecurityPolicy(); + $currentPolicy->addAllowedConnectDomain('currentPolicy'); + $mergedPolicy = new ContentSecurityPolicy(); + $mergedPolicy->addAllowedMediaDomain('mergedPolicy'); + $response + ->expects($this->exactly(2)) + ->method('getContentSecurityPolicy') + ->willReturn($currentPolicy); + $this->contentSecurityPolicyManager + ->expects($this->once()) + ->method('getDefaultPolicy') + ->willReturn($defaultPolicy); + $this->contentSecurityPolicyManager + ->expects($this->once()) + ->method('mergePolicies') + ->with($defaultPolicy, $currentPolicy) + ->willReturn($mergedPolicy); + $response->expects($this->once()) + ->method('setContentSecurityPolicy') + ->with($mergedPolicy); + + $this->middleware->afterController($this->controller, 'test', $response); + } + + public function testAfterControllerEmptyCSP(): void { + $response = $this->createMock(Response::class); + $emptyPolicy = new EmptyContentSecurityPolicy(); + $response->expects($this->any()) + ->method('getContentSecurityPolicy') + ->willReturn($emptyPolicy); + $response->expects($this->never()) + ->method('setContentSecurityPolicy'); + + $this->middleware->afterController($this->controller, 'test', $response); + } + + public function testAfterControllerWithContentSecurityPolicy3Support(): void { + $this->cspNonceManager + ->expects($this->once()) + ->method('browserSupportsCspV3') + ->willReturn(true); + $token = base64_encode('the-nonce'); + $this->cspNonceManager + ->expects($this->once()) + ->method('getNonce') + ->willReturn($token); + $response = $this->createMock(Response::class); + $defaultPolicy = new ContentSecurityPolicy(); + $defaultPolicy->addAllowedImageDomain('defaultpolicy'); + $currentPolicy = new ContentSecurityPolicy(); + $currentPolicy->addAllowedConnectDomain('currentPolicy'); + $mergedPolicy = new ContentSecurityPolicy(); + $mergedPolicy->addAllowedMediaDomain('mergedPolicy'); + $response + ->expects($this->exactly(2)) + ->method('getContentSecurityPolicy') + ->willReturn($currentPolicy); + $this->contentSecurityPolicyManager + ->expects($this->once()) + ->method('getDefaultPolicy') + ->willReturn($defaultPolicy); + $this->contentSecurityPolicyManager + ->expects($this->once()) + ->method('mergePolicies') + ->with($defaultPolicy, $currentPolicy) + ->willReturn($mergedPolicy); + $response->expects($this->once()) + ->method('setContentSecurityPolicy') + ->with($mergedPolicy); + + $this->assertEquals($response, $this->middleware->afterController($this->controller, 'test', $response)); + } +} diff --git a/tests/lib/AppFramework/Middleware/Security/FeaturePolicyMiddlewareTest.php b/tests/lib/AppFramework/Middleware/Security/FeaturePolicyMiddlewareTest.php new file mode 100644 index 00000000000..55a70d4c040 --- /dev/null +++ b/tests/lib/AppFramework/Middleware/Security/FeaturePolicyMiddlewareTest.php @@ -0,0 +1,69 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace Test\AppFramework\Middleware\Security; + +use OC\AppFramework\Middleware\Security\FeaturePolicyMiddleware; +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 PHPUnit\Framework\MockObject\MockObject; + +class FeaturePolicyMiddlewareTest extends \Test\TestCase { + /** @var FeaturePolicyMiddleware|MockObject */ + private $middleware; + /** @var Controller|MockObject */ + private $controller; + /** @var FeaturePolicyManager|MockObject */ + private $manager; + + protected function setUp(): void { + parent::setUp(); + + $this->controller = $this->createMock(Controller::class); + $this->manager = $this->createMock(FeaturePolicyManager::class); + $this->middleware = new FeaturePolicyMiddleware( + $this->manager + ); + } + + public function testAfterController(): void { + $response = $this->createMock(Response::class); + $defaultPolicy = new FeaturePolicy(); + $defaultPolicy->addAllowedCameraDomain('defaultpolicy'); + $currentPolicy = new FeaturePolicy(); + $currentPolicy->addAllowedAutoplayDomain('currentPolicy'); + $mergedPolicy = new FeaturePolicy(); + $mergedPolicy->addAllowedGeoLocationDomain('mergedPolicy'); + $response->method('getFeaturePolicy') + ->willReturn($currentPolicy); + $this->manager->method('getDefaultPolicy') + ->willReturn($defaultPolicy); + $this->manager->method('mergePolicies') + ->with($defaultPolicy, $currentPolicy) + ->willReturn($mergedPolicy); + $response->expects($this->once()) + ->method('setFeaturePolicy') + ->with($mergedPolicy); + + $this->middleware->afterController($this->controller, 'test', $response); + } + + public function testAfterControllerEmptyCSP(): void { + $response = $this->createMock(Response::class); + $emptyPolicy = new EmptyFeaturePolicy(); + $response->method('getFeaturePolicy') + ->willReturn($emptyPolicy); + $response->expects($this->never()) + ->method('setFeaturePolicy'); + + $this->middleware->afterController($this->controller, 'test', $response); + } +} diff --git a/tests/lib/AppFramework/Middleware/Security/Mock/CORSMiddlewareController.php b/tests/lib/AppFramework/Middleware/Security/Mock/CORSMiddlewareController.php new file mode 100644 index 00000000000..8ab3a48b62e --- /dev/null +++ b/tests/lib/AppFramework/Middleware/Security/Mock/CORSMiddlewareController.php @@ -0,0 +1,145 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace Test\AppFramework\Middleware\Security\Mock; + +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\CORS; +use OCP\AppFramework\Http\Attribute\PublicPage; + +class CORSMiddlewareController extends Controller { + /** + * @CORS + */ + public function testSetCORSAPIHeader() { + } + + #[CORS] + public function testSetCORSAPIHeaderAttribute() { + } + + public function testNoAnnotationNoCORSHEADER() { + } + + /** + * @CORS + */ + public function testNoOriginHeaderNoCORSHEADER() { + } + + #[CORS] + public function testNoOriginHeaderNoCORSHEADERAttribute() { + } + + /** + * @CORS + */ + public function testCorsIgnoredIfWithCredentialsHeaderPresent() { + } + + #[CORS] + public function testCorsAttributeIgnoredIfWithCredentialsHeaderPresent() { + } + + /** + * CORS must not be enforced for anonymous users on public pages + * + * @CORS + * @PublicPage + */ + public function testNoCORSOnAnonymousPublicPage() { + } + + /** + * CORS must not be enforced for anonymous users on public pages + * + * @CORS + */ + #[PublicPage] + public function testNoCORSOnAnonymousPublicPageAttribute() { + } + + /** + * @PublicPage + */ + #[CORS] + public function testNoCORSAttributeOnAnonymousPublicPage() { + } + + #[CORS] + #[PublicPage] + public function testNoCORSAttributeOnAnonymousPublicPageAttribute() { + } + + /** + * @CORS + * @PublicPage + */ + public function testCORSShouldNeverAllowCookieAuth() { + } + + /** + * @CORS + */ + #[PublicPage] + public function testCORSShouldNeverAllowCookieAuthAttribute() { + } + + /** + * @PublicPage + */ + #[CORS] + public function testCORSAttributeShouldNeverAllowCookieAuth() { + } + + #[CORS] + #[PublicPage] + public function testCORSAttributeShouldNeverAllowCookieAuthAttribute() { + } + + /** + * @CORS + */ + public function testCORSShouldRelogin() { + } + + #[CORS] + public function testCORSAttributeShouldRelogin() { + } + + /** + * @CORS + */ + public function testCORSShouldFailIfPasswordLoginIsForbidden() { + } + + #[CORS] + public function testCORSAttributeShouldFailIfPasswordLoginIsForbidden() { + } + + /** + * @CORS + */ + public function testCORSShouldNotAllowCookieAuth() { + } + + #[CORS] + public function testCORSAttributeShouldNotAllowCookieAuth() { + } + + public function testAfterExceptionWithSecurityExceptionNoStatus() { + } + + public function testAfterExceptionWithSecurityExceptionWithStatus() { + } + + + public function testAfterExceptionWithRegularException() { + } +} diff --git a/tests/lib/AppFramework/Middleware/Security/Mock/NormalController.php b/tests/lib/AppFramework/Middleware/Security/Mock/NormalController.php new file mode 100644 index 00000000000..4d6778e98b9 --- /dev/null +++ b/tests/lib/AppFramework/Middleware/Security/Mock/NormalController.php @@ -0,0 +1,17 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace Test\AppFramework\Middleware\Security\Mock; + +use OCP\AppFramework\Controller; + +class NormalController extends Controller { + public function foo() { + } +} diff --git a/tests/lib/AppFramework/Middleware/Security/Mock/OCSController.php b/tests/lib/AppFramework/Middleware/Security/Mock/OCSController.php new file mode 100644 index 00000000000..93e793ecca9 --- /dev/null +++ b/tests/lib/AppFramework/Middleware/Security/Mock/OCSController.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace Test\AppFramework\Middleware\Security\Mock; + +class OCSController extends \OCP\AppFramework\OCSController { + public function foo() { + } +} diff --git a/tests/lib/AppFramework/Middleware/Security/Mock/PasswordConfirmationMiddlewareController.php b/tests/lib/AppFramework/Middleware/Security/Mock/PasswordConfirmationMiddlewareController.php new file mode 100644 index 00000000000..cd1cdaa49ca --- /dev/null +++ b/tests/lib/AppFramework/Middleware/Security/Mock/PasswordConfirmationMiddlewareController.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace Test\AppFramework\Middleware\Security\Mock; + +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; + +class PasswordConfirmationMiddlewareController extends Controller { + public function testNoAnnotationNorAttribute() { + } + + /** + * @TestAnnotation + */ + public function testDifferentAnnotation() { + } + + /** + * @PasswordConfirmationRequired + */ + public function testAnnotation() { + } + + #[PasswordConfirmationRequired] + public function testAttribute() { + } + + #[PasswordConfirmationRequired] + public function testSSO() { + } +} diff --git a/tests/lib/AppFramework/Middleware/Security/Mock/SecurityMiddlewareController.php b/tests/lib/AppFramework/Middleware/Security/Mock/SecurityMiddlewareController.php new file mode 100644 index 00000000000..c8f9878b0c1 --- /dev/null +++ b/tests/lib/AppFramework/Middleware/Security/Mock/SecurityMiddlewareController.php @@ -0,0 +1,171 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace Test\AppFramework\Middleware\Security\Mock; + +use OCP\AppFramework\Controller; +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; + +class SecurityMiddlewareController extends Controller { + /** + * @PublicPage + * @NoCSRFRequired + */ + public function testAnnotationNoCSRFRequiredPublicPage() { + } + + /** + * @NoCSRFRequired + */ + #[PublicPage] + public function testAnnotationNoCSRFRequiredAttributePublicPage() { + } + + /** + * @PublicPage + */ + #[NoCSRFRequired] + public function testAnnotationPublicPageAttributeNoCSRFRequired() { + } + + #[NoCSRFRequired] + #[PublicPage] + public function testAttributeNoCSRFRequiredPublicPage() { + } + + public function testNoAnnotationNorAttribute() { + } + + /** + * @NoCSRFRequired + */ + public function testAnnotationNoCSRFRequired() { + } + + #[NoCSRFRequired] + public function testAttributeNoCSRFRequired() { + } + + /** + * @PublicPage + */ + public function testAnnotationPublicPage() { + } + + #[PublicPage] + public function testAttributePublicPage() { + } + + /** + * @PublicPage + * @StrictCookieRequired + */ + public function testAnnotationPublicPageStrictCookieRequired() { + } + + /** + * @StrictCookieRequired + */ + #[PublicPage] + public function testAnnotationStrictCookieRequiredAttributePublicPage() { + } + + /** + * @PublicPage + */ + #[StrictCookiesRequired] + public function testAnnotationPublicPageAttributeStrictCookiesRequired() { + } + + #[PublicPage] + #[StrictCookiesRequired] + public function testAttributePublicPageStrictCookiesRequired() { + } + + /** + * @PublicPage + * @NoCSRFRequired + * @StrictCookieRequired + */ + public function testAnnotationNoCSRFRequiredPublicPageStrictCookieRequired() { + } + + #[NoCSRFRequired] + #[PublicPage] + #[StrictCookiesRequired] + public function testAttributeNoCSRFRequiredPublicPageStrictCookiesRequired() { + } + + /** + * @NoCSRFRequired + * @NoAdminRequired + */ + public function testAnnotationNoAdminRequiredNoCSRFRequired() { + } + + #[NoAdminRequired] + #[NoCSRFRequired] + public function testAttributeNoAdminRequiredNoCSRFRequired() { + } + + /** + * @NoCSRFRequired + * @SubAdminRequired + */ + public function testAnnotationNoCSRFRequiredSubAdminRequired() { + } + + /** + * @SubAdminRequired + */ + #[NoCSRFRequired] + public function testAnnotationNoCSRFRequiredAttributeSubAdminRequired() { + } + + /** + * @NoCSRFRequired + */ + #[SubAdminRequired] + public function testAnnotationSubAdminRequiredAttributeNoCSRFRequired() { + } + + #[NoCSRFRequired] + #[SubAdminRequired] + public function testAttributeNoCSRFRequiredSubAdminRequired() { + } + + /** + * @PublicPage + * @NoAdminRequired + * @NoCSRFRequired + */ + public function testAnnotationNoAdminRequiredNoCSRFRequiredPublicPage() { + } + + #[NoAdminRequired] + #[NoCSRFRequired] + #[PublicPage] + public function testAttributeNoAdminRequiredNoCSRFRequiredPublicPage() { + } + + /** + * @ExAppRequired + */ + public function testAnnotationExAppRequired() { + } + + #[ExAppRequired] + public function testAttributeExAppRequired() { + } +} diff --git a/tests/lib/AppFramework/Middleware/Security/PasswordConfirmationMiddlewareTest.php b/tests/lib/AppFramework/Middleware/Security/PasswordConfirmationMiddlewareTest.php new file mode 100644 index 00000000000..90e801ca471 --- /dev/null +++ b/tests/lib/AppFramework/Middleware/Security/PasswordConfirmationMiddlewareTest.php @@ -0,0 +1,209 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace Test\AppFramework\Middleware\Security; + +use OC\AppFramework\Middleware\Security\Exceptions\NotConfirmedException; +use OC\AppFramework\Middleware\Security\PasswordConfirmationMiddleware; +use OC\AppFramework\Utility\ControllerMethodReflector; +use OC\Authentication\Token\IProvider; +use OC\User\Manager; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Authentication\Token\IToken; +use OCP\IRequest; +use OCP\ISession; +use OCP\IUser; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; +use Test\AppFramework\Middleware\Security\Mock\PasswordConfirmationMiddlewareController; +use Test\TestCase; + +class PasswordConfirmationMiddlewareTest extends TestCase { + /** @var ControllerMethodReflector */ + private $reflector; + /** @var ISession&\PHPUnit\Framework\MockObject\MockObject */ + private $session; + /** @var IUserSession&\PHPUnit\Framework\MockObject\MockObject */ + private $userSession; + /** @var IUser&\PHPUnit\Framework\MockObject\MockObject */ + private $user; + /** @var PasswordConfirmationMiddleware */ + private $middleware; + /** @var PasswordConfirmationMiddlewareController */ + private $controller; + /** @var ITimeFactory&\PHPUnit\Framework\MockObject\MockObject */ + private $timeFactory; + private IProvider&\PHPUnit\Framework\MockObject\MockObject $tokenProvider; + private LoggerInterface $logger; + /** @var IRequest&\PHPUnit\Framework\MockObject\MockObject */ + private IRequest $request; + /** @var Manager&\PHPUnit\Framework\MockObject\MockObject */ + private Manager $userManager; + + protected function setUp(): void { + $this->reflector = new ControllerMethodReflector(); + $this->session = $this->createMock(ISession::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->user = $this->createMock(IUser::class); + $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->tokenProvider = $this->createMock(IProvider::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->request = $this->createMock(IRequest::class); + $this->userManager = $this->createMock(Manager::class); + $this->controller = new PasswordConfirmationMiddlewareController( + 'test', + $this->createMock(IRequest::class) + ); + + $this->middleware = new PasswordConfirmationMiddleware( + $this->reflector, + $this->session, + $this->userSession, + $this->timeFactory, + $this->tokenProvider, + $this->logger, + $this->request, + $this->userManager, + ); + } + + public function testNoAnnotationNorAttribute(): void { + $this->reflector->reflect($this->controller, __FUNCTION__); + $this->session->expects($this->never()) + ->method($this->anything()); + $this->userSession->expects($this->never()) + ->method($this->anything()); + + $this->middleware->beforeController($this->controller, __FUNCTION__); + } + + public function testDifferentAnnotation(): void { + $this->reflector->reflect($this->controller, __FUNCTION__); + $this->session->expects($this->never()) + ->method($this->anything()); + $this->userSession->expects($this->never()) + ->method($this->anything()); + + $this->middleware->beforeController($this->controller, __FUNCTION__); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataProvider')] + public function testAnnotation($backend, $lastConfirm, $currentTime, $exception): void { + $this->reflector->reflect($this->controller, __FUNCTION__); + + $this->user->method('getBackendClassName') + ->willReturn($backend); + $this->userSession->method('getUser') + ->willReturn($this->user); + + $this->session->method('get') + ->with('last-password-confirm') + ->willReturn($lastConfirm); + + $this->timeFactory->method('getTime') + ->willReturn($currentTime); + + $token = $this->createMock(IToken::class); + $token->method('getScopeAsArray') + ->willReturn([]); + $this->tokenProvider->expects($this->once()) + ->method('getToken') + ->willReturn($token); + + $thrown = false; + try { + $this->middleware->beforeController($this->controller, __FUNCTION__); + } catch (NotConfirmedException $e) { + $thrown = true; + } + + $this->assertSame($exception, $thrown); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataProvider')] + public function testAttribute($backend, $lastConfirm, $currentTime, $exception): void { + $this->reflector->reflect($this->controller, __FUNCTION__); + + $this->user->method('getBackendClassName') + ->willReturn($backend); + $this->userSession->method('getUser') + ->willReturn($this->user); + + $this->session->method('get') + ->with('last-password-confirm') + ->willReturn($lastConfirm); + + $this->timeFactory->method('getTime') + ->willReturn($currentTime); + + $token = $this->createMock(IToken::class); + $token->method('getScopeAsArray') + ->willReturn([]); + $this->tokenProvider->expects($this->once()) + ->method('getToken') + ->willReturn($token); + + $thrown = false; + try { + $this->middleware->beforeController($this->controller, __FUNCTION__); + } catch (NotConfirmedException $e) { + $thrown = true; + } + + $this->assertSame($exception, $thrown); + } + + + + public static function dataProvider(): array { + return [ + ['foo', 2000, 4000, true], + ['foo', 2000, 3000, false], + ['user_saml', 2000, 4000, false], + ['user_saml', 2000, 3000, false], + ['foo', 2000, 3815, false], + ['foo', 2000, 3816, true], + ]; + } + + public function testSSO(): void { + static $sessionId = 'mySession1d'; + + $this->reflector->reflect($this->controller, __FUNCTION__); + + $this->user->method('getBackendClassName') + ->willReturn('fictional_backend'); + $this->userSession->method('getUser') + ->willReturn($this->user); + + $this->session->method('get') + ->with('last-password-confirm') + ->willReturn(0); + $this->session->method('getId') + ->willReturn($sessionId); + + $this->timeFactory->method('getTime') + ->willReturn(9876); + + $token = $this->createMock(IToken::class); + $token->method('getScopeAsArray') + ->willReturn([IToken::SCOPE_SKIP_PASSWORD_VALIDATION => true]); + $this->tokenProvider->expects($this->once()) + ->method('getToken') + ->with($sessionId) + ->willReturn($token); + + $thrown = false; + try { + $this->middleware->beforeController($this->controller, __FUNCTION__); + } catch (NotConfirmedException) { + $thrown = true; + } + + $this->assertSame(false, $thrown); + } +} diff --git a/tests/lib/AppFramework/Middleware/Security/RateLimitingMiddlewareTest.php b/tests/lib/AppFramework/Middleware/Security/RateLimitingMiddlewareTest.php new file mode 100644 index 00000000000..c42baadcb1c --- /dev/null +++ b/tests/lib/AppFramework/Middleware/Security/RateLimitingMiddlewareTest.php @@ -0,0 +1,322 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace Test\AppFramework\Middleware\Security; + +use OC\AppFramework\Middleware\Security\RateLimitingMiddleware; +use OC\AppFramework\Utility\ControllerMethodReflector; +use OC\Security\Ip\BruteforceAllowList; +use OC\Security\RateLimiting\Exception\RateLimitExceededException; +use OC\Security\RateLimiting\Limiter; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\AnonRateLimit; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\IAppConfig; +use OCP\IRequest; +use OCP\ISession; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use Test\TestCase; + +class TestRateLimitController extends Controller { + /** + * @UserRateThrottle(limit=20, period=200) + * @AnonRateThrottle(limit=10, period=100) + */ + public function testMethodWithAnnotation() { + } + + /** + * @AnonRateThrottle(limit=10, period=100) + */ + public function testMethodWithAnnotationFallback() { + } + + public function testMethodWithoutAnnotation() { + } + + #[UserRateLimit(limit: 20, period: 200)] + #[AnonRateLimit(limit: 10, period: 100)] + public function testMethodWithAttributes() { + } + + #[AnonRateLimit(limit: 10, period: 100)] + public function testMethodWithAttributesFallback() { + } +} + +/** + * @group DB + */ +class RateLimitingMiddlewareTest extends TestCase { + private IRequest|MockObject $request; + private IUserSession|MockObject $userSession; + private ControllerMethodReflector $reflector; + private Limiter|MockObject $limiter; + private ISession|MockObject $session; + private IAppConfig|MockObject $appConfig; + private BruteforceAllowList|MockObject $bruteForceAllowList; + private RateLimitingMiddleware $rateLimitingMiddleware; + + protected function setUp(): void { + parent::setUp(); + + $this->request = $this->createMock(IRequest::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->reflector = new ControllerMethodReflector(); + $this->limiter = $this->createMock(Limiter::class); + $this->session = $this->createMock(ISession::class); + $this->appConfig = $this->createMock(IAppConfig::class); + $this->bruteForceAllowList = $this->createMock(BruteforceAllowList::class); + + $this->rateLimitingMiddleware = new RateLimitingMiddleware( + $this->request, + $this->userSession, + $this->reflector, + $this->limiter, + $this->session, + $this->appConfig, + $this->bruteForceAllowList, + ); + } + + public function testBeforeControllerWithoutAnnotationForAnon(): void { + $this->limiter + ->expects($this->never()) + ->method('registerUserRequest'); + $this->limiter + ->expects($this->never()) + ->method('registerAnonRequest'); + + $this->userSession->expects($this->once()) + ->method('isLoggedIn') + ->willReturn(false); + + /** @var TestRateLimitController|MockObject $controller */ + $controller = $this->createMock(TestRateLimitController::class); + $this->reflector->reflect($controller, 'testMethodWithoutAnnotation'); + $this->rateLimitingMiddleware->beforeController($controller, 'testMethodWithoutAnnotation'); + } + + public function testBeforeControllerWithoutAnnotationForLoggedIn(): void { + $this->limiter + ->expects($this->never()) + ->method('registerUserRequest'); + $this->limiter + ->expects($this->never()) + ->method('registerAnonRequest'); + + $this->userSession->expects($this->once()) + ->method('isLoggedIn') + ->willReturn(true); + + /** @var TestRateLimitController|MockObject $controller */ + $controller = $this->createMock(TestRateLimitController::class); + $this->reflector->reflect($controller, 'testMethodWithoutAnnotation'); + $this->rateLimitingMiddleware->beforeController($controller, 'testMethodWithoutAnnotation'); + } + + public function testBeforeControllerForAnon(): void { + $controller = new TestRateLimitController('test', $this->request); + + $this->request + ->method('getRemoteAddress') + ->willReturn('127.0.0.1'); + + $this->userSession + ->expects($this->once()) + ->method('isLoggedIn') + ->willReturn(false); + + $this->limiter + ->expects($this->never()) + ->method('registerUserRequest'); + $this->limiter + ->expects($this->once()) + ->method('registerAnonRequest') + ->with(get_class($controller) . '::testMethodWithAnnotation', '10', '100', '127.0.0.1'); + + $this->reflector->reflect($controller, 'testMethodWithAnnotation'); + $this->rateLimitingMiddleware->beforeController($controller, 'testMethodWithAnnotation'); + } + + public function testBeforeControllerForLoggedIn(): void { + $controller = new TestRateLimitController('test', $this->request); + /** @var IUser|MockObject $user */ + $user = $this->createMock(IUser::class); + + $this->userSession + ->expects($this->once()) + ->method('isLoggedIn') + ->willReturn(true); + $this->userSession + ->expects($this->once()) + ->method('getUser') + ->willReturn($user); + + $this->limiter + ->expects($this->never()) + ->method('registerAnonRequest'); + $this->limiter + ->expects($this->once()) + ->method('registerUserRequest') + ->with(get_class($controller) . '::testMethodWithAnnotation', '20', '200', $user); + + + $this->reflector->reflect($controller, 'testMethodWithAnnotation'); + $this->rateLimitingMiddleware->beforeController($controller, 'testMethodWithAnnotation'); + } + + public function testBeforeControllerAnonWithFallback(): void { + $controller = new TestRateLimitController('test', $this->request); + $this->request + ->expects($this->once()) + ->method('getRemoteAddress') + ->willReturn('127.0.0.1'); + + $this->userSession + ->expects($this->once()) + ->method('isLoggedIn') + ->willReturn(true); + + + $this->limiter + ->expects($this->never()) + ->method('registerUserRequest'); + $this->limiter + ->expects($this->once()) + ->method('registerAnonRequest') + ->with(get_class($controller) . '::testMethodWithAnnotationFallback', '10', '100', '127.0.0.1'); + + $this->reflector->reflect($controller, 'testMethodWithAnnotationFallback'); + $this->rateLimitingMiddleware->beforeController($controller, 'testMethodWithAnnotationFallback'); + } + + public function testBeforeControllerAttributesForAnon(): void { + $controller = new TestRateLimitController('test', $this->request); + + $this->request + ->method('getRemoteAddress') + ->willReturn('127.0.0.1'); + + $this->userSession + ->expects($this->once()) + ->method('isLoggedIn') + ->willReturn(false); + + $this->limiter + ->expects($this->never()) + ->method('registerUserRequest'); + $this->limiter + ->expects($this->once()) + ->method('registerAnonRequest') + ->with(get_class($controller) . '::testMethodWithAttributes', '10', '100', '127.0.0.1'); + + $this->reflector->reflect($controller, 'testMethodWithAttributes'); + $this->rateLimitingMiddleware->beforeController($controller, 'testMethodWithAttributes'); + } + + public function testBeforeControllerAttributesForLoggedIn(): void { + $controller = new TestRateLimitController('test', $this->request); + /** @var IUser|MockObject $user */ + $user = $this->createMock(IUser::class); + + $this->userSession + ->expects($this->once()) + ->method('isLoggedIn') + ->willReturn(true); + $this->userSession + ->expects($this->once()) + ->method('getUser') + ->willReturn($user); + + $this->limiter + ->expects($this->never()) + ->method('registerAnonRequest'); + $this->limiter + ->expects($this->once()) + ->method('registerUserRequest') + ->with(get_class($controller) . '::testMethodWithAttributes', '20', '200', $user); + + + $this->reflector->reflect($controller, 'testMethodWithAttributes'); + $this->rateLimitingMiddleware->beforeController($controller, 'testMethodWithAttributes'); + } + + public function testBeforeControllerAttributesAnonWithFallback(): void { + $controller = new TestRateLimitController('test', $this->request); + $this->request + ->expects($this->once()) + ->method('getRemoteAddress') + ->willReturn('127.0.0.1'); + + $this->userSession + ->expects($this->once()) + ->method('isLoggedIn') + ->willReturn(true); + + + $this->limiter + ->expects($this->never()) + ->method('registerUserRequest'); + $this->limiter + ->expects($this->once()) + ->method('registerAnonRequest') + ->with(get_class($controller) . '::testMethodWithAttributesFallback', '10', '100', '127.0.0.1'); + + $this->reflector->reflect($controller, 'testMethodWithAttributesFallback'); + $this->rateLimitingMiddleware->beforeController($controller, 'testMethodWithAttributesFallback'); + } + + public function testAfterExceptionWithOtherException(): void { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('My test exception'); + + $controller = new TestRateLimitController('test', $this->request); + + $this->rateLimitingMiddleware->afterException($controller, 'testMethod', new \Exception('My test exception')); + } + + public function testAfterExceptionWithJsonBody(): void { + $controller = new TestRateLimitController('test', $this->request); + $this->request + ->expects($this->once()) + ->method('getHeader') + ->with('Accept') + ->willReturn('JSON'); + + $result = $this->rateLimitingMiddleware->afterException($controller, 'testMethod', new RateLimitExceededException()); + $expected = new DataResponse([], 429 + ); + $this->assertEquals($expected, $result); + } + + public function testAfterExceptionWithHtmlBody(): void { + $controller = new TestRateLimitController('test', $this->request); + $this->request + ->expects($this->once()) + ->method('getHeader') + ->with('Accept') + ->willReturn('html'); + + $result = $this->rateLimitingMiddleware->afterException($controller, 'testMethod', new RateLimitExceededException()); + $expected = new TemplateResponse( + 'core', + '429', + [], + TemplateResponse::RENDER_AS_GUEST + ); + $expected->setStatus(429); + $this->assertEquals($expected, $result); + $this->assertIsString($result->render()); + } +} diff --git a/tests/lib/AppFramework/Middleware/Security/SameSiteCookieMiddlewareTest.php b/tests/lib/AppFramework/Middleware/Security/SameSiteCookieMiddlewareTest.php new file mode 100644 index 00000000000..7800371f68f --- /dev/null +++ b/tests/lib/AppFramework/Middleware/Security/SameSiteCookieMiddlewareTest.php @@ -0,0 +1,120 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace Test\AppFramework\Middleware\Security; + +use OC\AppFramework\Http\Request; +use OC\AppFramework\Middleware\Security\Exceptions\LaxSameSiteCookieFailedException; +use OC\AppFramework\Middleware\Security\Exceptions\SecurityException; +use OC\AppFramework\Middleware\Security\SameSiteCookieMiddleware; +use OC\AppFramework\Utility\ControllerMethodReflector; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use Test\TestCase; + +class SameSiteCookieMiddlewareTest extends TestCase { + /** @var SameSiteCookieMiddleware */ + private $middleware; + + /** @var Request|\PHPUnit\Framework\MockObject\MockObject */ + private $request; + + /** @var ControllerMethodReflector|\PHPUnit\Framework\MockObject\MockObject */ + private $reflector; + + protected function setUp(): void { + parent::setUp(); + + $this->request = $this->createMock(Request::class); + $this->reflector = $this->createMock(ControllerMethodReflector::class); + $this->middleware = new SameSiteCookieMiddleware($this->request, $this->reflector); + } + + public function testBeforeControllerNoIndex(): void { + $this->request->method('getScriptName') + ->willReturn('/ocs/v2.php'); + + $this->middleware->beforeController($this->createMock(Controller::class), 'foo'); + $this->addToAssertionCount(1); + } + + public function testBeforeControllerIndexHasAnnotation(): void { + $this->request->method('getScriptName') + ->willReturn('/index.php'); + + $this->reflector->method('hasAnnotation') + ->with('NoSameSiteCookieRequired') + ->willReturn(true); + + $this->middleware->beforeController($this->createMock(Controller::class), 'foo'); + $this->addToAssertionCount(1); + } + + public function testBeforeControllerIndexNoAnnotationPassingCheck(): void { + $this->request->method('getScriptName') + ->willReturn('/index.php'); + + $this->reflector->method('hasAnnotation') + ->with('NoSameSiteCookieRequired') + ->willReturn(false); + + $this->request->method('passesLaxCookieCheck') + ->willReturn(true); + + $this->middleware->beforeController($this->createMock(Controller::class), 'foo'); + $this->addToAssertionCount(1); + } + + public function testBeforeControllerIndexNoAnnotationFailingCheck(): void { + $this->expectException(LaxSameSiteCookieFailedException::class); + + $this->request->method('getScriptName') + ->willReturn('/index.php'); + + $this->reflector->method('hasAnnotation') + ->with('NoSameSiteCookieRequired') + ->willReturn(false); + + $this->request->method('passesLaxCookieCheck') + ->willReturn(false); + + $this->middleware->beforeController($this->createMock(Controller::class), 'foo'); + } + + public function testAfterExceptionNoLaxCookie(): void { + $ex = new SecurityException(); + + try { + $this->middleware->afterException($this->createMock(Controller::class), 'foo', $ex); + $this->fail(); + } catch (\Exception $e) { + $this->assertSame($ex, $e); + } + } + + public function testAfterExceptionLaxCookie(): void { + $ex = new LaxSameSiteCookieFailedException(); + + $this->request->method('getRequestUri') + ->willReturn('/myrequri'); + + $middleware = $this->getMockBuilder(SameSiteCookieMiddleware::class) + ->setConstructorArgs([$this->request, $this->reflector]) + ->onlyMethods(['setSameSiteCookie']) + ->getMock(); + + $middleware->expects($this->once()) + ->method('setSameSiteCookie'); + + $resp = $middleware->afterException($this->createMock(Controller::class), 'foo', $ex); + + $this->assertSame(Http::STATUS_FOUND, $resp->getStatus()); + + $headers = $resp->getHeaders(); + $this->assertSame('/myrequri', $headers['Location']); + } +} diff --git a/tests/lib/AppFramework/Middleware/Security/SecurityMiddlewareTest.php b/tests/lib/AppFramework/Middleware/Security/SecurityMiddlewareTest.php new file mode 100644 index 00000000000..0c6fc21357d --- /dev/null +++ b/tests/lib/AppFramework/Middleware/Security/SecurityMiddlewareTest.php @@ -0,0 +1,701 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace Test\AppFramework\Middleware\Security; + +use OC\AppFramework\Http; +use OC\AppFramework\Http\Request; +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\Middleware\Security\SecurityMiddleware; +use OC\AppFramework\Utility\ControllerMethodReflector; +use OC\Settings\AuthorizedGroupMapper; +use OC\User\Session; +use OCP\App\IAppManager; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\Group\ISubAdmin; +use OCP\IConfig; +use OCP\IGroupManager; +use OCP\IL10N; +use OCP\INavigationManager; +use OCP\IRequest; +use OCP\IRequestId; +use OCP\ISession; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserSession; +use OCP\Security\Ip\IRemoteAddress; +use Psr\Log\LoggerInterface; +use Test\AppFramework\Middleware\Security\Mock\NormalController; +use Test\AppFramework\Middleware\Security\Mock\OCSController; +use Test\AppFramework\Middleware\Security\Mock\SecurityMiddlewareController; + +class SecurityMiddlewareTest extends \Test\TestCase { + /** @var SecurityMiddleware|\PHPUnit\Framework\MockObject\MockObject */ + private $middleware; + /** @var SecurityMiddlewareController */ + private $controller; + /** @var SecurityException */ + private $secException; + /** @var SecurityException */ + private $secAjaxException; + /** @var IRequest|\PHPUnit\Framework\MockObject\MockObject */ + private $request; + /** @var ControllerMethodReflector */ + private $reader; + /** @var LoggerInterface|\PHPUnit\Framework\MockObject\MockObject */ + private $logger; + /** @var INavigationManager|\PHPUnit\Framework\MockObject\MockObject */ + private $navigationManager; + /** @var IURLGenerator|\PHPUnit\Framework\MockObject\MockObject */ + private $urlGenerator; + /** @var IAppManager|\PHPUnit\Framework\MockObject\MockObject */ + private $appManager; + /** @var IL10N|\PHPUnit\Framework\MockObject\MockObject */ + private $l10n; + /** @var IUserSession|\PHPUnit\Framework\MockObject\MockObject */ + private $userSession; + /** @var AuthorizedGroupMapper|\PHPUnit\Framework\MockObject\MockObject */ + private $authorizedGroupMapper; + + protected function setUp(): void { + parent::setUp(); + + $this->authorizedGroupMapper = $this->createMock(AuthorizedGroupMapper::class); + $this->userSession = $this->createMock(Session::class); + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('test'); + $this->userSession->method('getUser')->willReturn($user); + $this->request = $this->createMock(IRequest::class); + $this->controller = new SecurityMiddlewareController( + 'test', + $this->request + ); + $this->reader = new ControllerMethodReflector(); + $this->logger = $this->createMock(LoggerInterface::class); + $this->navigationManager = $this->createMock(INavigationManager::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->l10n = $this->createMock(IL10N::class); + $this->middleware = $this->getMiddleware(true, true, false); + $this->secException = new SecurityException('hey', false); + $this->secAjaxException = new SecurityException('hey', true); + } + + private function getMiddleware(bool $isLoggedIn, bool $isAdminUser, bool $isSubAdmin, bool $isAppEnabledForUser = true): SecurityMiddleware { + $this->appManager = $this->createMock(IAppManager::class); + $this->appManager->expects($this->any()) + ->method('isEnabledForUser') + ->willReturn($isAppEnabledForUser); + $remoteIpAddress = $this->createMock(IRemoteAddress::class); + $remoteIpAddress->method('allowsAdminActions')->willReturn(true); + + $groupManager = $this->createMock(IGroupManager::class); + $groupManager->method('isAdmin') + ->willReturn($isAdminUser); + $subAdminManager = $this->createMock(ISubAdmin::class); + $subAdminManager->method('isSubAdmin') + ->willReturn($isSubAdmin); + + return new SecurityMiddleware( + $this->request, + $this->reader, + $this->navigationManager, + $this->urlGenerator, + $this->logger, + 'files', + $isLoggedIn, + $groupManager, + $subAdminManager, + $this->appManager, + $this->l10n, + $this->authorizedGroupMapper, + $this->userSession, + $remoteIpAddress + ); + } + + public static function dataNoCSRFRequiredPublicPage(): array { + return [ + ['testAnnotationNoCSRFRequiredPublicPage'], + ['testAnnotationNoCSRFRequiredAttributePublicPage'], + ['testAnnotationPublicPageAttributeNoCSRFRequired'], + ['testAttributeNoCSRFRequiredPublicPage'], + ]; + } + + public static function dataPublicPage(): array { + return [ + ['testAnnotationPublicPage'], + ['testAttributePublicPage'], + ]; + } + + public static function dataNoCSRFRequired(): array { + return [ + ['testAnnotationNoCSRFRequired'], + ['testAttributeNoCSRFRequired'], + ]; + } + + public static function dataPublicPageStrictCookieRequired(): array { + return [ + ['testAnnotationPublicPageStrictCookieRequired'], + ['testAnnotationStrictCookieRequiredAttributePublicPage'], + ['testAnnotationPublicPageAttributeStrictCookiesRequired'], + ['testAttributePublicPageStrictCookiesRequired'], + ]; + } + + public static function dataNoCSRFRequiredPublicPageStrictCookieRequired(): array { + return [ + ['testAnnotationNoCSRFRequiredPublicPageStrictCookieRequired'], + ['testAttributeNoCSRFRequiredPublicPageStrictCookiesRequired'], + ]; + } + + public static function dataNoAdminRequiredNoCSRFRequired(): array { + return [ + ['testAnnotationNoAdminRequiredNoCSRFRequired'], + ['testAttributeNoAdminRequiredNoCSRFRequired'], + ]; + } + + public static function dataNoAdminRequiredNoCSRFRequiredPublicPage(): array { + return [ + ['testAnnotationNoAdminRequiredNoCSRFRequiredPublicPage'], + ['testAttributeNoAdminRequiredNoCSRFRequiredPublicPage'], + ]; + } + + public static function dataNoCSRFRequiredSubAdminRequired(): array { + return [ + ['testAnnotationNoCSRFRequiredSubAdminRequired'], + ['testAnnotationNoCSRFRequiredAttributeSubAdminRequired'], + ['testAnnotationSubAdminRequiredAttributeNoCSRFRequired'], + ['testAttributeNoCSRFRequiredSubAdminRequired'], + ]; + } + + public static function dataExAppRequired(): array { + return [ + ['testAnnotationExAppRequired'], + ['testAttributeExAppRequired'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredPublicPage')] + public function testSetNavigationEntry(string $method): void { + $this->navigationManager->expects($this->once()) + ->method('setActiveEntry') + ->with($this->equalTo('files')); + + $this->reader->reflect($this->controller, $method); + $this->middleware->beforeController($this->controller, $method); + } + + + /** + * @param string $method + * @param string $test + */ + private function ajaxExceptionStatus($method, $test, $status) { + $isLoggedIn = false; + $isAdminUser = false; + + // isAdminUser requires isLoggedIn call to return true + if ($test === 'isAdminUser') { + $isLoggedIn = true; + } + + $sec = $this->getMiddleware($isLoggedIn, $isAdminUser, false); + + try { + $this->reader->reflect($this->controller, $method); + $sec->beforeController($this->controller, $method); + } catch (SecurityException $ex) { + $this->assertEquals($status, $ex->getCode()); + } + + // add assertion if everything should work fine otherwise phpunit will + // complain + if ($status === 0) { + $this->addToAssertionCount(1); + } + } + + public function testAjaxStatusLoggedInCheck(): void { + $this->ajaxExceptionStatus( + 'testNoAnnotationNorAttribute', + 'isLoggedIn', + Http::STATUS_UNAUTHORIZED + ); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequired')] + public function testAjaxNotAdminCheck(string $method): void { + $this->ajaxExceptionStatus( + $method, + 'isAdminUser', + Http::STATUS_FORBIDDEN + ); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataPublicPage')] + public function testAjaxStatusCSRFCheck(string $method): void { + $this->ajaxExceptionStatus( + $method, + 'passesCSRFCheck', + Http::STATUS_PRECONDITION_FAILED + ); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredPublicPage')] + public function testAjaxStatusAllGood(string $method): void { + $this->ajaxExceptionStatus( + $method, + 'isLoggedIn', + 0 + ); + $this->ajaxExceptionStatus( + $method, + 'isAdminUser', + 0 + ); + $this->ajaxExceptionStatus( + $method, + 'passesCSRFCheck', + 0 + ); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredPublicPage')] + public function testNoChecks(string $method): void { + $this->request->expects($this->never()) + ->method('passesCSRFCheck') + ->willReturn(false); + + $sec = $this->getMiddleware(false, false, false); + + $this->reader->reflect($this->controller, $method); + $sec->beforeController($this->controller, $method); + } + + /** + * @param string $method + * @param string $expects + */ + private function securityCheck($method, $expects, $shouldFail = false) { + // admin check requires login + if ($expects === 'isAdminUser') { + $isLoggedIn = true; + $isAdminUser = !$shouldFail; + } else { + $isLoggedIn = !$shouldFail; + $isAdminUser = false; + } + + $sec = $this->getMiddleware($isLoggedIn, $isAdminUser, false); + + if ($shouldFail) { + $this->expectException(SecurityException::class); + } else { + $this->addToAssertionCount(1); + } + + $this->reader->reflect($this->controller, $method); + $sec->beforeController($this->controller, $method); + } + + + #[\PHPUnit\Framework\Attributes\DataProvider('dataPublicPage')] + public function testCsrfCheck(string $method): void { + $this->expectException(CrossSiteRequestForgeryException::class); + + $this->request->expects($this->once()) + ->method('passesCSRFCheck') + ->willReturn(false); + $this->request->expects($this->once()) + ->method('passesStrictCookieCheck') + ->willReturn(true); + $this->reader->reflect($this->controller, $method); + $this->middleware->beforeController($this->controller, $method); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredPublicPage')] + public function testNoCsrfCheck(string $method): void { + $this->request->expects($this->never()) + ->method('passesCSRFCheck') + ->willReturn(false); + + $this->reader->reflect($this->controller, $method); + $this->middleware->beforeController($this->controller, $method); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataPublicPage')] + public function testPassesCsrfCheck(string $method): void { + $this->request->expects($this->once()) + ->method('passesCSRFCheck') + ->willReturn(true); + $this->request->expects($this->once()) + ->method('passesStrictCookieCheck') + ->willReturn(true); + + $this->reader->reflect($this->controller, $method); + $this->middleware->beforeController($this->controller, $method); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataPublicPage')] + public function testFailCsrfCheck(string $method): void { + $this->expectException(CrossSiteRequestForgeryException::class); + + $this->request->expects($this->once()) + ->method('passesCSRFCheck') + ->willReturn(false); + $this->request->expects($this->once()) + ->method('passesStrictCookieCheck') + ->willReturn(true); + + $this->reader->reflect($this->controller, $method); + $this->middleware->beforeController($this->controller, $method); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataPublicPageStrictCookieRequired')] + public function testStrictCookieRequiredCheck(string $method): void { + $this->expectException(\OC\AppFramework\Middleware\Security\Exceptions\StrictCookieMissingException::class); + + $this->request->expects($this->never()) + ->method('passesCSRFCheck'); + $this->request->expects($this->once()) + ->method('passesStrictCookieCheck') + ->willReturn(false); + + $this->reader->reflect($this->controller, $method); + $this->middleware->beforeController($this->controller, $method); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredPublicPage')] + public function testNoStrictCookieRequiredCheck(string $method): void { + $this->request->expects($this->never()) + ->method('passesStrictCookieCheck') + ->willReturn(false); + + $this->reader->reflect($this->controller, $method); + $this->middleware->beforeController($this->controller, $method); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredPublicPageStrictCookieRequired')] + public function testPassesStrictCookieRequiredCheck(string $method): void { + $this->request + ->expects($this->once()) + ->method('passesStrictCookieCheck') + ->willReturn(true); + + $this->reader->reflect($this->controller, $method); + $this->middleware->beforeController($this->controller, $method); + } + + public static function dataCsrfOcsController(): array { + return [ + [NormalController::class, false, false, true], + [NormalController::class, false, true, true], + [NormalController::class, true, false, true], + [NormalController::class, true, true, true], + + [OCSController::class, false, false, true], + [OCSController::class, false, true, false], + [OCSController::class, true, false, false], + [OCSController::class, true, true, false], + ]; + } + + /** + * @param string $controllerClass + * @param bool $hasOcsApiHeader + * @param bool $hasBearerAuth + * @param bool $exception + */ + #[\PHPUnit\Framework\Attributes\DataProvider('dataCsrfOcsController')] + public function testCsrfOcsController(string $controllerClass, bool $hasOcsApiHeader, bool $hasBearerAuth, bool $exception): void { + $this->request + ->method('getHeader') + ->willReturnCallback(function ($header) use ($hasOcsApiHeader, $hasBearerAuth) { + if ($header === 'OCS-APIREQUEST' && $hasOcsApiHeader) { + return 'true'; + } + if ($header === 'Authorization' && $hasBearerAuth) { + return 'Bearer TOKEN!'; + } + return ''; + }); + $this->request->expects($this->once()) + ->method('passesStrictCookieCheck') + ->willReturn(true); + + $controller = new $controllerClass('test', $this->request); + + try { + $this->middleware->beforeController($controller, 'foo'); + $this->assertFalse($exception); + } catch (CrossSiteRequestForgeryException $e) { + $this->assertTrue($exception); + } + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoAdminRequiredNoCSRFRequired')] + public function testLoggedInCheck(string $method): void { + $this->securityCheck($method, 'isLoggedIn'); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoAdminRequiredNoCSRFRequired')] + public function testFailLoggedInCheck(string $method): void { + $this->securityCheck($method, 'isLoggedIn', true); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequired')] + public function testIsAdminCheck(string $method): void { + $this->securityCheck($method, 'isAdminUser'); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredSubAdminRequired')] + public function testIsNotSubAdminCheck(string $method): void { + $this->reader->reflect($this->controller, $method); + $sec = $this->getMiddleware(true, false, false); + + $this->expectException(SecurityException::class); + $sec->beforeController($this->controller, $method); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredSubAdminRequired')] + public function testIsSubAdminCheck(string $method): void { + $this->reader->reflect($this->controller, $method); + $sec = $this->getMiddleware(true, false, true); + + $sec->beforeController($this->controller, $method); + $this->addToAssertionCount(1); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredSubAdminRequired')] + public function testIsSubAdminAndAdminCheck(string $method): void { + $this->reader->reflect($this->controller, $method); + $sec = $this->getMiddleware(true, true, true); + + $sec->beforeController($this->controller, $method); + $this->addToAssertionCount(1); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequired')] + public function testFailIsAdminCheck(string $method): void { + $this->securityCheck($method, 'isAdminUser', true); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoAdminRequiredNoCSRFRequiredPublicPage')] + public function testRestrictedAppLoggedInPublicPage(string $method): void { + $middleware = $this->getMiddleware(true, false, false); + $this->reader->reflect($this->controller, $method); + + $this->appManager->method('getAppPath') + ->with('files') + ->willReturn('foo'); + + $this->appManager->method('isEnabledForUser') + ->with('files') + ->willReturn(false); + + $middleware->beforeController($this->controller, $method); + $this->addToAssertionCount(1); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoAdminRequiredNoCSRFRequiredPublicPage')] + public function testRestrictedAppNotLoggedInPublicPage(string $method): void { + $middleware = $this->getMiddleware(false, false, false); + $this->reader->reflect($this->controller, $method); + + $this->appManager->method('getAppPath') + ->with('files') + ->willReturn('foo'); + + $this->appManager->method('isEnabledForUser') + ->with('files') + ->willReturn(false); + + $middleware->beforeController($this->controller, $method); + $this->addToAssertionCount(1); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoAdminRequiredNoCSRFRequired')] + public function testRestrictedAppLoggedIn(string $method): void { + $middleware = $this->getMiddleware(true, false, false, false); + $this->reader->reflect($this->controller, $method); + + $this->appManager->method('getAppPath') + ->with('files') + ->willReturn('foo'); + + $this->expectException(AppNotEnabledException::class); + $middleware->beforeController($this->controller, $method); + } + + + public function testAfterExceptionNotCaughtThrowsItAgain(): void { + $ex = new \Exception(); + $this->expectException(\Exception::class); + $this->middleware->afterException($this->controller, 'test', $ex); + } + + public function testAfterExceptionReturnsRedirectForNotLoggedInUser(): void { + $this->request = new Request( + [ + 'server' + => [ + 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'REQUEST_URI' => 'nextcloud/index.php/apps/specialapp' + ] + ], + $this->createMock(IRequestId::class), + $this->createMock(IConfig::class) + ); + $this->middleware = $this->getMiddleware(false, false, false); + $this->urlGenerator + ->expects($this->once()) + ->method('linkToRoute') + ->with( + 'core.login.showLoginForm', + [ + 'redirect_url' => 'nextcloud/index.php/apps/specialapp', + ] + ) + ->willReturn('http://localhost/nextcloud/index.php/login?redirect_url=nextcloud/index.php/apps/specialapp'); + $this->logger + ->expects($this->once()) + ->method('debug'); + $response = $this->middleware->afterException( + $this->controller, + 'test', + new NotLoggedInException() + ); + $expected = new RedirectResponse('http://localhost/nextcloud/index.php/login?redirect_url=nextcloud/index.php/apps/specialapp'); + $this->assertEquals($expected, $response); + } + + public function testAfterExceptionRedirectsToWebRootAfterStrictCookieFail(): void { + $this->request = new Request( + [ + 'server' => [ + 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'REQUEST_URI' => 'nextcloud/index.php/apps/specialapp', + ], + ], + $this->createMock(IRequestId::class), + $this->createMock(IConfig::class) + ); + + $this->middleware = $this->getMiddleware(false, false, false); + $response = $this->middleware->afterException( + $this->controller, + 'test', + new StrictCookieMissingException() + ); + + $expected = new RedirectResponse(\OC::$WEBROOT . '/'); + $this->assertEquals($expected, $response); + } + + + /** + * @return array + */ + public static function exceptionProvider(): array { + return [ + [ + new AppNotEnabledException(), + ], + [ + new CrossSiteRequestForgeryException(), + ], + [ + new NotAdminException(''), + ], + ]; + } + + /** + * @param SecurityException $exception + */ + #[\PHPUnit\Framework\Attributes\DataProvider('exceptionProvider')] + public function testAfterExceptionReturnsTemplateResponse(SecurityException $exception): void { + $this->request = new Request( + [ + 'server' + => [ + 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'REQUEST_URI' => 'nextcloud/index.php/apps/specialapp' + ] + ], + $this->createMock(IRequestId::class), + $this->createMock(IConfig::class) + ); + $this->middleware = $this->getMiddleware(false, false, false); + $this->logger + ->expects($this->once()) + ->method('debug'); + $response = $this->middleware->afterException( + $this->controller, + 'test', + $exception + ); + $expected = new TemplateResponse('core', '403', ['message' => $exception->getMessage()], 'guest'); + $expected->setStatus($exception->getCode()); + $this->assertEquals($expected, $response); + } + + public function testAfterAjaxExceptionReturnsJSONError(): void { + $response = $this->middleware->afterException($this->controller, 'test', + $this->secAjaxException); + + $this->assertTrue($response instanceof JSONResponse); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataExAppRequired')] + public function testExAppRequired(string $method): void { + $middleware = $this->getMiddleware(true, false, false); + $this->reader->reflect($this->controller, $method); + + $session = $this->createMock(ISession::class); + $session->method('get')->with('app_api')->willReturn(true); + $this->userSession->method('getSession')->willReturn($session); + + $this->request->expects($this->once()) + ->method('passesStrictCookieCheck') + ->willReturn(true); + $this->request->expects($this->once()) + ->method('passesCSRFCheck') + ->willReturn(true); + + $middleware->beforeController($this->controller, $method); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataExAppRequired')] + public function testExAppRequiredError(string $method): void { + $middleware = $this->getMiddleware(true, false, false, false); + $this->reader->reflect($this->controller, $method); + + $session = $this->createMock(ISession::class); + $session->method('get')->with('app_api')->willReturn(false); + $this->userSession->method('getSession')->willReturn($session); + + $this->expectException(ExAppRequiredException::class); + $middleware->beforeController($this->controller, $method); + } +} |