From e28ee6dd68c641a1e999ac7a4a5a675135ad1b8c Mon Sep 17 00:00:00 2001 From: dartcafe Date: Wed, 30 Sep 2026 08:54:12 +0200 Subject: [PATCH 1/4] limit enumeration of user names for public endpoints Signed-off-by: dartcafe --- lib/AppInfo/Application.php | 8 +++ lib/Controller/PublicController.php | 4 ++ lib/Controller/ShareApiController.php | 3 + lib/Service/SystemService.php | 2 + src/components/Public/RegistrationForm.vue | 13 ++++ .../User/ActionInputDisplayName.vue | 6 +- src/components/User/UserMenu.vue | 6 +- src/components/VoteTable/VoteMenu.vue | 6 +- src/helpers/modules/rateLimit.ts | 27 ++++++++ tests/Unit/Controller/PublicRateLimitTest.php | 67 +++++++++++++++++++ 10 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 src/helpers/modules/rateLimit.ts create mode 100644 tests/Unit/Controller/PublicRateLimitTest.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 419f18f219..d200232888 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -87,6 +87,14 @@ class Application extends App implements IBootstrap { public const CLIENT_TZ = 'ncPollsClientTimeZone'; /** @var string */ public const SESSION_KEY_CRON_JOB = 'ncPollsCronJob'; + /** + * Anonymous rate limit for public endpoints (requests per IP and period) + * Admins can overwrite it per route via 'ratelimit_overwrite' (NC 33+) + * @var int + */ + public const PUBLIC_RATE_LIMIT = 100; + /** @var int period in seconds */ + public const PUBLIC_RATE_LIMIT_PERIOD = 300; public function __construct(array $urlParams = []) { parent::__construct(self::APP_ID, $urlParams); diff --git a/lib/Controller/PublicController.php b/lib/Controller/PublicController.php index a2964b9989..b9b7534d7d 100644 --- a/lib/Controller/PublicController.php +++ b/lib/Controller/PublicController.php @@ -27,6 +27,7 @@ use OCA\Polls\Service\WatchService; use OCA\Polls\UserSession; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AnonRateLimit; use OCP\AppFramework\Http\Attribute\FrontpageRoute; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\OpenAPI; @@ -396,6 +397,7 @@ public function unsubscribe(): JSONResponse { */ #[PublicPage] #[ShareTokenRequired] + #[AnonRateLimit(limit: Application::PUBLIC_RATE_LIMIT, period: Application::PUBLIC_RATE_LIMIT_PERIOD)] #[OpenAPI(OpenAPI::SCOPE_IGNORE)] #[FrontpageRoute(verb: 'POST', url: '/check/username')] public function validatePublicDisplayName(string $displayName, string $token): JSONResponse { @@ -440,6 +442,7 @@ public function validateEmailAddress(string $emailAddress): JSONResponse { */ #[PublicPage] #[ShareTokenRequired] + #[AnonRateLimit(limit: Application::PUBLIC_RATE_LIMIT, period: Application::PUBLIC_RATE_LIMIT_PERIOD)] #[OpenAPI(OpenAPI::SCOPE_IGNORE)] #[FrontpageRoute(verb: 'PUT', url: '/s/{token}/name/{displayName}')] public function setDisplayName(string $token, string $displayName): JSONResponse { @@ -487,6 +490,7 @@ public function deleteEmailAddress(string $token): JSONResponse { */ #[PublicPage] #[ShareTokenRequired] + #[AnonRateLimit(limit: Application::PUBLIC_RATE_LIMIT, period: Application::PUBLIC_RATE_LIMIT_PERIOD)] #[OpenAPI(OpenAPI::SCOPE_IGNORE)] #[FrontpageRoute(verb: 'POST', url: '/s/{token}/register')] public function register(string $token, string $displayName, string $emailAddress = '', string $timeZone = ''): JSONResponse { diff --git a/lib/Controller/ShareApiController.php b/lib/Controller/ShareApiController.php index 27b4564ec7..c9cb437016 100644 --- a/lib/Controller/ShareApiController.php +++ b/lib/Controller/ShareApiController.php @@ -8,11 +8,13 @@ namespace OCA\Polls\Controller; +use OCA\Polls\AppInfo\Application; use OCA\Polls\Attributes\ShareTokenRequired; use OCA\Polls\Model\SentResult; use OCA\Polls\Service\MailService; use OCA\Polls\Service\ShareService; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AnonRateLimit; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\CORS; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -79,6 +81,7 @@ public function get(string $token): DataResponse { #[ShareTokenRequired] #[NoAdminRequired] #[NoCSRFRequired] + #[AnonRateLimit(limit: Application::PUBLIC_RATE_LIMIT, period: Application::PUBLIC_RATE_LIMIT_PERIOD)] #[ApiRoute(verb: 'POST', url: 'api/v1.0/s/{token}/register')] public function register(string $token, string $displayName, string $emailAddress = '', string $timeZone = ''): DataResponse { return $this->response(fn () => [ diff --git a/lib/Service/SystemService.php b/lib/Service/SystemService.php index f0d70fdfd4..e2d8c9fec8 100644 --- a/lib/Service/SystemService.php +++ b/lib/Service/SystemService.php @@ -265,6 +265,8 @@ public function validatePublicUsernameByToken(string $userName, string $token): public function getRandomDisplayName(string $token): string { $share = $this->shareMapper->findByToken($token); + $userName = ''; + for ($i = 0; $i < 10; $i++) { $userName = NameGenerator::generateRandom(); try { diff --git a/src/components/Public/RegistrationForm.vue b/src/components/Public/RegistrationForm.vue index f88a312ef1..d4ce1fafe1 100644 --- a/src/components/Public/RegistrationForm.vue +++ b/src/components/Public/RegistrationForm.vue @@ -17,6 +17,7 @@ import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwit import InputDiv from '@/components/Base/modules/InputDiv.vue' import { PublicAPI, ValidatorAPI } from '@/Api' import { setCookie } from '@/helpers/modules/cookieHelper' +import { isRateLimited, showRateLimitError } from '@/helpers/modules/rateLimit' import { useSessionStore } from '@/stores/session' const route = useRoute() @@ -57,6 +58,9 @@ const userNameHint = computed(() => { username: userName.value, }) } + if (checkStatus.value.userName === 'error') { + return t('polls', 'Too many requests. Please wait a few minutes and try again.') + } return '' }) @@ -76,6 +80,11 @@ const validatePublicUsername = debounce(async function (): Promise { return } + if (isRateLimited(error)) { + checkStatus.value.userName = 'error' + return + } + checkStatus.value.userName = 'invalid' if ((error as AxiosError).code === 'ERR_BAD_REQUEST') { @@ -186,6 +195,10 @@ async function submitRegistration(): Promise { if ((error as AxiosError)?.code === 'ERR_CANCELED') { return } + if (isRateLimited(error)) { + showRateLimitError() + return + } showError(t('polls', 'Error registering to poll', { error })) throw error } finally { diff --git a/src/components/User/ActionInputDisplayName.vue b/src/components/User/ActionInputDisplayName.vue index 3efb4ebba0..1dfd45bdb6 100644 --- a/src/components/User/ActionInputDisplayName.vue +++ b/src/components/User/ActionInputDisplayName.vue @@ -14,6 +14,7 @@ import { useRoute } from 'vue-router' import NcActionInput from '@nextcloud/vue/components/NcActionInput' import EditAccountIcon from 'vue-material-design-icons/AccountEditOutline.vue' import { ValidatorAPI } from '../../Api/index.ts' +import { isRateLimited, showRateLimitError } from '../../helpers/modules/rateLimit.ts' import { useSessionStore } from '../../stores/session.ts' const route = useRoute() @@ -64,7 +65,10 @@ const validatePublicUsername = debounce(async function () { sessionStore.share.user.displayName, ) setStatus('success') - } catch { + } catch (error) { + if (isRateLimited(error)) { + showRateLimitError() + } setStatus('error') } }, 500) diff --git a/src/components/User/UserMenu.vue b/src/components/User/UserMenu.vue index e2f3caf056..8ca7e2a443 100644 --- a/src/components/User/UserMenu.vue +++ b/src/components/User/UserMenu.vue @@ -40,6 +40,7 @@ import { deleteCookieByValue, findCookieByValue, } from '../../helpers/modules/cookieHelper.ts' +import { isRateLimited, showRateLimitError } from '../../helpers/modules/rateLimit.ts' import { useOptionsStore } from '../../stores/options.ts' import { usePollStore } from '../../stores/poll.ts' import { useSessionStore } from '../../stores/session.ts' @@ -227,7 +228,10 @@ const validateDisplayName = debounce(async function () { sessionStore.share.user.displayName, ) setDisplayNameStatus('success') - } catch { + } catch (error) { + if (isRateLimited(error)) { + showRateLimitError() + } setDisplayNameStatus('error') } }, 500) diff --git a/src/components/VoteTable/VoteMenu.vue b/src/components/VoteTable/VoteMenu.vue index 10caa381a6..13320b08bb 100644 --- a/src/components/VoteTable/VoteMenu.vue +++ b/src/components/VoteTable/VoteMenu.vue @@ -29,6 +29,7 @@ import { deleteCookieByValue, findCookieByValue, } from '../../helpers/modules/cookieHelper.ts' +import { isRateLimited, showRateLimitError } from '../../helpers/modules/rateLimit.ts' import { usePollStore } from '../../stores/poll.ts' import { useSessionStore } from '../../stores/session.ts' import { useSubscriptionStore } from '../../stores/subscription.ts' @@ -173,7 +174,10 @@ const validateDisplayName = debounce(async function () { sessionStore.share.user.displayName, ) setDisplayNameStatus('success') - } catch { + } catch (error) { + if (isRateLimited(error)) { + showRateLimitError() + } setDisplayNameStatus('error') } }, 500) diff --git a/src/helpers/modules/rateLimit.ts b/src/helpers/modules/rateLimit.ts new file mode 100644 index 0000000000..e169b5940a --- /dev/null +++ b/src/helpers/modules/rateLimit.ts @@ -0,0 +1,27 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { AxiosError } from '@nextcloud/axios' + +import { showError } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' + +/** + * Check, if the request was rejected by the server's rate limiter + * + * @param error error thrown by axios + */ +function isRateLimited(error: unknown): boolean { + return (error as AxiosError)?.response?.status === 429 +} + +/** + * Inform the user about the rate limitation + */ +function showRateLimitError(): void { + showError(t('polls', 'Too many requests. Please wait a few minutes and try again.')) +} + +export { isRateLimited, showRateLimitError } diff --git a/tests/Unit/Controller/PublicRateLimitTest.php b/tests/Unit/Controller/PublicRateLimitTest.php new file mode 100644 index 0000000000..abc63043ac --- /dev/null +++ b/tests/Unit/Controller/PublicRateLimitTest.php @@ -0,0 +1,67 @@ + + */ + public static function nameValidatingEndpoints(): array { + return [ + 'check username' => [PublicController::class, 'validatePublicDisplayName'], + 'change display name' => [PublicController::class, 'setDisplayName'], + 'register' => [PublicController::class, 'register'], + 'register (OCS)' => [ShareApiController::class, 'register'], + ]; + } + + /** + * @param class-string $controller + */ + #[DataProvider('nameValidatingEndpoints')] + public function testEndpointHasAnonRateLimit(string $controller, string $method): void { + $attributes = (new ReflectionMethod($controller, $method))->getAttributes(AnonRateLimit::class); + + $this->assertCount(1, $attributes, "$controller::$method must declare #[AnonRateLimit]"); + + $rateLimit = $attributes[0]->newInstance(); + $this->assertSame(Application::PUBLIC_RATE_LIMIT, $rateLimit->getLimit()); + $this->assertSame(Application::PUBLIC_RATE_LIMIT_PERIOD, $rateLimit->getPeriod()); + } + + /** + * Without a UserRateLimit the middleware applies the anon limit to logged-in + * users as well. A (more generous) user limit would open a bypass by logging in. + * + * @param class-string $controller + */ + #[DataProvider('nameValidatingEndpoints')] + public function testEndpointHasNoUserRateLimit(string $controller, string $method): void { + $attributes = (new ReflectionMethod($controller, $method))->getAttributes(UserRateLimit::class); + + $this->assertCount(0, $attributes, "$controller::$method must not declare #[UserRateLimit]"); + } +} From da51e8f99df06467ef44960dd78970140fae487b Mon Sep 17 00:00:00 2001 From: dartcafe Date: Wed, 30 Sep 2026 08:54:29 +0200 Subject: [PATCH 2/4] add a test for mandatory attributes Signed-off-by: dartcafe --- lib/Controller/PublicController.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/Controller/PublicController.php b/lib/Controller/PublicController.php index b9b7534d7d..20cc091d43 100644 --- a/lib/Controller/PublicController.php +++ b/lib/Controller/PublicController.php @@ -407,8 +407,7 @@ public function validatePublicDisplayName(string $displayName, string $token): J } /** - * Validate it the user name is reserved - * return false, if this username already exists as a user or as a participant of the poll + * Roll the dice to generate a random username * @param string $token Share token */ #[PublicPage] From 4119d43bf40940e04edeb66e1c7949e9b230fa6d Mon Sep 17 00:00:00 2001 From: dartcafe Date: Wed, 30 Sep 2026 10:37:16 +0200 Subject: [PATCH 3/4] Added the core's bruteForce protection against share token enumeration Signed-off-by: dartcafe --- lib/Controller/ShareApiController.php | 1 + .../RequestAttributesMiddleware.php | 93 ++++++-- lib/Service/SystemService.php | 5 +- .../RequestAttributesMiddlewareTest.php | 199 ++++++++++++++++++ 4 files changed, 280 insertions(+), 18 deletions(-) create mode 100644 tests/Unit/Middleware/RequestAttributesMiddlewareTest.php diff --git a/lib/Controller/ShareApiController.php b/lib/Controller/ShareApiController.php index c9cb437016..aef1ad8008 100644 --- a/lib/Controller/ShareApiController.php +++ b/lib/Controller/ShareApiController.php @@ -61,6 +61,7 @@ public function list(int $pollId): DataResponse { #[CORS] #[PublicPage] #[NoAdminRequired] + #[ShareTokenRequired] #[NoCSRFRequired] #[ApiRoute(verb: 'GET', url: '/api/v1.0/share/{token}')] public function get(string $token): DataResponse { diff --git a/lib/Middleware/RequestAttributesMiddleware.php b/lib/Middleware/RequestAttributesMiddleware.php index 741e681648..45a37df24e 100644 --- a/lib/Middleware/RequestAttributesMiddleware.php +++ b/lib/Middleware/RequestAttributesMiddleware.php @@ -7,12 +7,26 @@ namespace OCA\Polls\Middleware; +use Exception; use OCA\Polls\Attributes\ShareTokenRequired; +use OCA\Polls\Exceptions\ShareNotFoundException; use OCA\Polls\UserSession; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\NotFoundResponse; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\TooManyRequestsResponse; use OCP\AppFramework\Middleware; +use OCP\AppFramework\OCS\OCSException; +use OCP\AppFramework\OCS\OCSNotFoundException; +use OCP\AppFramework\OCSController; use OCP\IRequest; use OCP\ISession; +use OCP\IURLGenerator; +use OCP\Security\Bruteforce\IThrottler; +use OCP\Security\Bruteforce\MaxDelayReached; use ReflectionMethod; class RequestAttributesMiddleware extends Middleware { @@ -20,12 +34,17 @@ class RequestAttributesMiddleware extends Middleware { private const TIME_ZONE_KEY = 'Nc-Polls-Client-Time-Zone'; private const LANGUAGE_KEY = 'Nc-Polls-Client-Language'; private const SHARE_TOKEN = 'Nc-Polls-Share-Token'; + public const BRUTEFORCE_ACTION = 'polls_share_token'; + + private ?ShareNotFoundException $rejectedToken = null; /** @psalm-suppress PossiblyUnusedMethod */ public function __construct( protected IRequest $request, protected ISession $session, protected UserSession $userSession, + protected IThrottler $throttler, + protected IURLGenerator $urlGenerator, ) { } @@ -53,9 +72,66 @@ public function beforeController(Controller $controller, string $methodName): vo $this->userSession->setClientLanguage($clientLanguage); } - if ($this->hasAttribute($reflectionMethod, ShareTokenRequired::class)) { + if (!empty($reflectionMethod->getAttributes(ShareTokenRequired::class))) { $this->userSession->setShareToken($this->getShareTokenFromURI()); + $this->protectShareToken(); + } + } + + /** + * Slow down share token enumeration by using the server's brute force protection + * + * Unknown tokens are registered as failed attempts. Subsequent requests from + * the same IP get delayed and finally blocked, according to the admin's + * brute force settings (auth.bruteforce.*, bruteforcesettings allow list). + * + * @throws MaxDelayReached if the maximum of failed attempts is reached + * @throws ShareNotFoundException if the share token does not exist + */ + private function protectShareToken(): void { + $remoteAddress = $this->request->getRemoteAddress(); + $this->throttler->sleepDelayOrThrowOnMax($remoteAddress, self::BRUTEFORCE_ACTION); + + // Requests without token are left to the controller, nothing to enumerate here + if (!$this->userSession->hasShare()) { + return; + } + + try { + $this->userSession->getShare(); + } catch (ShareNotFoundException $e) { + $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $remoteAddress); + $this->rejectedToken = $e; + throw $e; + } + } + + public function afterException(Controller $controller, string $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(); } + + // only handle the rejection from beforeController, not exceptions thrown by the controller + if ($exception === $this->rejectedToken) { + if ($controller instanceof OCSController) { + throw new OCSNotFoundException($exception->getMessage()); + } + + if (stripos($this->request->getHeader('Accept'), 'html') === false) { + return new JSONResponse(['message' => $exception->getMessage()], Http::STATUS_NOT_FOUND); + } + + // Page request (e.g. /s/{token}): guests get redirected to the login page + if ($this->userSession->getIsLoggedIn()) { + return new NotFoundResponse(); + } + return new RedirectResponse($this->urlGenerator->linkToRoute('core.login.showLoginForm')); + } + + throw $exception; } private function getShareTokenFromURI(): string { @@ -79,19 +155,4 @@ private function getShareTokenFromURI(): string { return ''; } - /** - * @template T - * - * @param ReflectionMethod $reflectionMethod - * @param class-string $attributeClass - * @return boolean - */ - protected function hasAttribute(ReflectionMethod $reflectionMethod, string $attributeClass): bool { - if (empty($reflectionMethod->getAttributes($attributeClass))) { - return false; - } - - return true; - } - } diff --git a/lib/Service/SystemService.php b/lib/Service/SystemService.php index e2d8c9fec8..9d52f4f8da 100644 --- a/lib/Service/SystemService.php +++ b/lib/Service/SystemService.php @@ -265,18 +265,19 @@ public function validatePublicUsernameByToken(string $userName, string $token): public function getRandomDisplayName(string $token): string { $share = $this->shareMapper->findByToken($token); - $userName = ''; for ($i = 0; $i < 10; $i++) { $userName = NameGenerator::generateRandom(); try { + // TODO: avoid exzessive checks, every call uses a complete + // db heavy roundtrip for username evaluation $this->validatePublicUsername($userName, $share); return $userName; } catch (InvalidUsernameException|TooShortException) { $userName = ''; } } - return $userName; + return ''; } /** diff --git a/tests/Unit/Middleware/RequestAttributesMiddlewareTest.php b/tests/Unit/Middleware/RequestAttributesMiddlewareTest.php new file mode 100644 index 0000000000..c6cc0d61bb --- /dev/null +++ b/tests/Unit/Middleware/RequestAttributesMiddlewareTest.php @@ -0,0 +1,199 @@ +request = $this->createMock(IRequest::class); + $this->request->method('getRemoteAddress')->willReturn(self::REMOTE_ADDRESS); + $this->userSession = $this->createMock(UserSession::class); + $this->throttler = $this->createMock(IThrottler::class); + $urlGenerator = $this->createMock(IURLGenerator::class); + $urlGenerator->method('linkToRoute')->with('core.login.showLoginForm')->willReturn(self::LOGIN_URL); + + $this->middleware = new RequestAttributesMiddleware( + $this->request, + $this->createMock(ISession::class), + $this->userSession, + $this->throttler, + $urlGenerator, + ); + $this->controller = new TokenTestController('polls', $this->request); + $this->ocsController = new TokenTestOCSController('polls', $this->request); + } + + private function withToken(string $token, string $accept = self::ACCEPT_JSON): void { + $this->request->method('getParam')->willReturnMap([['token', null, $token]]); + $this->request->method('getHeader')->willReturnCallback( + fn (string $name) => $name === 'Accept' ? $accept : '' + ); + $this->userSession->method('hasShare')->willReturn($token !== ''); + } + + /** + * Run the middleware with an unknown token and return the rejection + */ + private function rejectUnknownToken(Controller $controller, string $accept = self::ACCEPT_JSON): ShareNotFoundException { + $this->withToken('unknownToken', $accept); + $exception = new ShareNotFoundException(); + $this->userSession->method('getShare')->willThrowException($exception); + + try { + $this->middleware->beforeController($controller, 'validated'); + $this->fail('Unknown token must be rejected'); + } catch (ShareNotFoundException $e) { + $this->assertSame($exception, $e); + } + return $exception; + } + + public function testValidTokenIsNotRegisteredAsFailedAttempt(): void { + $this->withToken('validToken'); + $this->userSession->expects($this->once())->method('setShareToken')->with('validToken'); + $this->userSession->method('getShare')->willReturn($this->createMock(Share::class)); + + $this->throttler->expects($this->once()) + ->method('sleepDelayOrThrowOnMax') + ->with(self::REMOTE_ADDRESS, RequestAttributesMiddleware::BRUTEFORCE_ACTION); + $this->throttler->expects($this->never())->method('registerAttempt'); + + $this->middleware->beforeController($this->controller, 'validated'); + } + + public function testUnknownTokenIsRegisteredAndRejectedWithJson(): void { + $this->throttler->expects($this->once()) + ->method('registerAttempt') + ->with(RequestAttributesMiddleware::BRUTEFORCE_ACTION, self::REMOTE_ADDRESS); + + $exception = $this->rejectUnknownToken($this->controller); + + $response = $this->middleware->afterException($this->controller, 'validated', $exception); + $this->assertInstanceOf(JSONResponse::class, $response); + $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus()); + } + + public function testUnknownTokenOnPageRedirectsGuestToLogin(): void { + $this->userSession->method('getIsLoggedIn')->willReturn(false); + $exception = $this->rejectUnknownToken($this->controller, self::ACCEPT_HTML); + + $response = $this->middleware->afterException($this->controller, 'validated', $exception); + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame(self::LOGIN_URL, $response->getRedirectURL()); + } + + public function testUnknownTokenOnPageShowsNotFoundToLoggedInUser(): void { + $this->userSession->method('getIsLoggedIn')->willReturn(true); + $exception = $this->rejectUnknownToken($this->controller, self::ACCEPT_HTML); + + $response = $this->middleware->afterException($this->controller, 'validated', $exception); + $this->assertInstanceOf(NotFoundResponse::class, $response); + } + + public function testUnknownTokenOnOcsControllerThrowsOcsNotFound(): void { + $exception = $this->rejectUnknownToken($this->ocsController); + + $this->expectException(OCSNotFoundException::class); + $this->middleware->afterException($this->ocsController, 'validated', $exception); + } + + public function testMissingTokenIsLeftToController(): void { + $this->withToken(''); + $this->userSession->expects($this->never())->method('getShare'); + $this->throttler->expects($this->never())->method('registerAttempt'); + + $this->middleware->beforeController($this->controller, 'validated'); + } + + public function testMethodWithoutAttributeIsNotProtected(): void { + $this->userSession->expects($this->never())->method('setShareToken'); + $this->throttler->expects($this->never())->method('sleepDelayOrThrowOnMax'); + + $this->middleware->beforeController($this->controller, 'withoutToken'); + } + + public function testMaxDelayReachedReturnsTooManyRequests(): void { + $response = $this->middleware->afterException($this->controller, 'validated', new MaxDelayReached()); + + $this->assertInstanceOf(TooManyRequestsResponse::class, $response); + $this->assertSame(Http::STATUS_TOO_MANY_REQUESTS, $response->getStatus()); + } + + public function testMaxDelayReachedOnOcsControllerThrowsOcsException(): void { + try { + $this->middleware->afterException($this->ocsController, 'validated', new MaxDelayReached()); + $this->fail('OCSException expected'); + } catch (OCSException $e) { + $this->assertSame(Http::STATUS_TOO_MANY_REQUESTS, $e->getCode()); + } + } + + /** + * ShareNotFoundExceptions thrown by the controller itself are not + * registered as failed attempts and must be passed through + */ + public function testForeignShareNotFoundExceptionIsPassedThrough(): void { + $exception = new ShareNotFoundException(); + + $this->expectExceptionObject($exception); + $this->middleware->afterException($this->controller, 'validated', $exception); + } +} From 73d41cfb183239c86ed919094f3cdeb638d1da8a Mon Sep 17 00:00:00 2001 From: dartcafe Date: Wed, 30 Sep 2026 11:58:34 +0200 Subject: [PATCH 4/4] psalm fix Signed-off-by: dartcafe --- lib/Service/SystemService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Service/SystemService.php b/lib/Service/SystemService.php index 9d52f4f8da..22456868c7 100644 --- a/lib/Service/SystemService.php +++ b/lib/Service/SystemService.php @@ -274,7 +274,7 @@ public function getRandomDisplayName(string $token): string { $this->validatePublicUsername($userName, $share); return $userName; } catch (InvalidUsernameException|TooShortException) { - $userName = ''; + continue; } } return '';