Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 5 additions & 2 deletions lib/Controller/PublicController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -405,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]
Expand Down Expand Up @@ -440,6 +441,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 {
Expand Down Expand Up @@ -487,6 +489,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 {
Expand Down
4 changes: 4 additions & 0 deletions lib/Controller/ShareApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -59,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 {
Expand All @@ -79,6 +82,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 () => [
Expand Down
93 changes: 77 additions & 16 deletions lib/Middleware/RequestAttributesMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,44 @@

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 {
private const CLIENT_ID_KEY = 'Nc-Polls-Client-Id';
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,
) {
}

Expand Down Expand Up @@ -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 {
Expand All @@ -79,19 +155,4 @@ private function getShareTokenFromURI(): string {

return '';
}
/**
* @template T
*
* @param ReflectionMethod $reflectionMethod
* @param class-string<T> $attributeClass
* @return boolean
*/
protected function hasAttribute(ReflectionMethod $reflectionMethod, string $attributeClass): bool {
if (empty($reflectionMethod->getAttributes($attributeClass))) {
return false;
}

return true;
}

}
7 changes: 5 additions & 2 deletions lib/Service/SystemService.php
Original file line number Diff line number Diff line change
Expand Up @@ -265,16 +265,19 @@ public function validatePublicUsernameByToken(string $userName, string $token):

public function getRandomDisplayName(string $token): string {
$share = $this->shareMapper->findByToken($token);

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 = '';
continue;
}
}
return $userName;
return '';
}

/**
Expand Down
13 changes: 13 additions & 0 deletions src/components/Public/RegistrationForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 ''
})

Expand All @@ -76,6 +80,11 @@ const validatePublicUsername = debounce(async function (): Promise<void> {
return
}

if (isRateLimited(error)) {
checkStatus.value.userName = 'error'
return
}

checkStatus.value.userName = 'invalid'

if ((error as AxiosError).code === 'ERR_BAD_REQUEST') {
Expand Down Expand Up @@ -186,6 +195,10 @@ async function submitRegistration(): Promise<void> {
if ((error as AxiosError)?.code === 'ERR_CANCELED') {
return
}
if (isRateLimited(error)) {
showRateLimitError()
return
}
showError(t('polls', 'Error registering to poll', { error }))
throw error
} finally {
Expand Down
6 changes: 5 additions & 1 deletion src/components/User/ActionInputDisplayName.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion src/components/User/UserMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion src/components/VoteTable/VoteMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions src/helpers/modules/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -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 }
Loading
Loading