Commit 939003e5 authored by Torben Hansen's avatar Torben Hansen Committed by Benni Mack
Browse files

[FEATURE] Introduce rate limiter for failed logins

The PHP library `symfony/rate-limiter` has been integrated
in order to provide a rate limiting API for the TYPO3 core
and extensions.

As a new system default, the TYPO3 backend and
frontend login now uses a rate limiter, which prevents
further authentication attempts for an IP address, if
a configurable amount of login attempts is
exceeded in a given time.

The hardcoded wait time of 5 seconds after a failed login has
been removed, since it offers no real protection against brute
force attacks.

The following dependencies are introduced:

* symfony/rate-limiter "^5.3"

Resolves: #93825
Releases: master
Change-Id: Ib248b78b501a4d50556aa97938f4c51f12f7522a
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/68624

Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Jochen's avatarJochen <rothjochen@gmail.com>
Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Reviewed-by: Jochen's avatarJochen <rothjochen@gmail.com>
Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
parent ef218070
......@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "4cc6f8e7d9eff80f921541c99c4841e5",
"content-hash": "ca0cef227667409039995378723d0f49",
"packages": [
{
"name": "bacon/bacon-qr-code",
......@@ -2799,6 +2799,86 @@
],
"time": "2020-03-27T16:56:45+00:00"
},
{
"name": "symfony/lock",
"version": "v5.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/lock.git",
"reference": "1f166823d4307eecd9f964804afefa2a59b9a3cf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/lock/zipball/1f166823d4307eecd9f964804afefa2a59b9a3cf",
"reference": "1f166823d4307eecd9f964804afefa2a59b9a3cf",
"shasum": ""
},
"require": {
"php": ">=7.2.5",
"psr/log": "~1.0",
"symfony/deprecation-contracts": "^2.1",
"symfony/polyfill-php80": "^1.15"
},
"conflict": {
"doctrine/dbal": "<2.10"
},
"require-dev": {
"doctrine/dbal": "^2.10|^3.0",
"mongodb/mongodb": "~1.1",
"predis/predis": "~1.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Lock\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jérémy Derussé",
"email": "jeremy@derusse.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Creates and manages locks, a mechanism to provide exclusive access to a shared resource",
"homepage": "https://symfony.com",
"keywords": [
"cas",
"flock",
"locking",
"mutex",
"redlock",
"semaphore"
],
"support": {
"source": "https://github.com/symfony/lock/tree/v5.3.2"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2021-06-06T09:51:56+00:00"
},
{
"name": "symfony/mailer",
"version": "v5.3.0",
......@@ -2957,6 +3037,75 @@
],
"time": "2021-05-26T17:43:10+00:00"
},
{
"name": "symfony/options-resolver",
"version": "v5.2.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
"reference": "87a2a4a766244e796dd9cb9d6f58c123358cd986"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/87a2a4a766244e796dd9cb9d6f58c123358cd986",
"reference": "87a2a4a766244e796dd9cb9d6f58c123358cd986",
"shasum": ""
},
"require": {
"php": ">=7.2.5",
"symfony/deprecation-contracts": "^2.1",
"symfony/polyfill-php73": "~1.0",
"symfony/polyfill-php80": "^1.15"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\OptionsResolver\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony OptionsResolver Component",
"homepage": "https://symfony.com",
"keywords": [
"config",
"configuration",
"options"
],
"support": {
"source": "https://github.com/symfony/options-resolver/tree/v5.2.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2020-10-24T12:08:07+00:00"
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.23.0",
......@@ -3931,6 +4080,76 @@
],
"time": "2021-05-31T12:40:48+00:00"
},
{
"name": "symfony/rate-limiter",
"version": "v5.3.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/rate-limiter.git",
"reference": "e9226c91163495ff0b655cdae0fff682e869640b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/rate-limiter/zipball/e9226c91163495ff0b655cdae0fff682e869640b",
"reference": "e9226c91163495ff0b655cdae0fff682e869640b",
"shasum": ""
},
"require": {
"php": ">=7.2.5",
"symfony/lock": "^5.2",
"symfony/options-resolver": "^5.1"
},
"require-dev": {
"psr/cache": "^1.0|^2.0|^3.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\RateLimiter\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Wouter de Jong",
"email": "wouter@wouterj.nl"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides a Token Bucket implementation to rate limit input and output in your application",
"homepage": "https://symfony.com",
"keywords": [
"limiter",
"rate-limiter"
],
"support": {
"source": "https://github.com/symfony/rate-limiter/tree/v5.3.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2021-05-26T17:43:10+00:00"
},
{
"name": "symfony/routing",
"version": "v5.3.0",
......@@ -7276,75 +7495,6 @@
],
"time": "2020-03-27T16:56:45+00:00"
},
{
"name": "symfony/options-resolver",
"version": "v5.2.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
"reference": "87a2a4a766244e796dd9cb9d6f58c123358cd986"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/87a2a4a766244e796dd9cb9d6f58c123358cd986",
"reference": "87a2a4a766244e796dd9cb9d6f58c123358cd986",
"shasum": ""
},
"require": {
"php": ">=7.2.5",
"symfony/deprecation-contracts": "^2.1",
"symfony/polyfill-php73": "~1.0",
"symfony/polyfill-php80": "^1.15"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\OptionsResolver\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony OptionsResolver Component",
"homepage": "https://symfony.com",
"keywords": [
"config",
"configuration",
"options"
],
"support": {
"source": "https://github.com/symfony/options-resolver/tree/v5.2.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2020-10-24T12:08:07+00:00"
},
{
"name": "symfony/polyfill-php70",
"version": "v1.20.0",
......@@ -7742,5 +7892,5 @@
"platform-overrides": {
"php": "7.4.1"
},
"plugin-api-version": "2.0.0"
"plugin-api-version": "2.1.0"
}
......@@ -20,6 +20,9 @@ namespace TYPO3\CMS\Backend\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\RateLimiter\LimiterInterface;
use TYPO3\CMS\Backend\Routing\Route;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
......@@ -31,16 +34,21 @@ use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
use TYPO3\CMS\Core\Messaging\AbstractMessage;
use TYPO3\CMS\Core\RateLimiter\RateLimiterFactory;
use TYPO3\CMS\Core\RateLimiter\RequestRateLimitedException;
use TYPO3\CMS\Core\Session\UserSessionManager;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\HttpUtility;
/**
* Initializes the backend user authentication object (BE_USER) and the global LANG object.
*
* @internal
*/
class BackendUserAuthenticator extends \TYPO3\CMS\Core\Middleware\BackendUserAuthenticator
class BackendUserAuthenticator extends \TYPO3\CMS\Core\Middleware\BackendUserAuthenticator implements LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* List of requests that don't need a valid BE user
*
......@@ -62,13 +70,16 @@ class BackendUserAuthenticator extends \TYPO3\CMS\Core\Middleware\BackendUserAut
];
private LanguageServiceFactory $languageServiceFactory;
private RateLimiterFactory $rateLimiterFactory;
public function __construct(
Context $context,
LanguageServiceFactory $languageServiceFactory
LanguageServiceFactory $languageServiceFactory,
RateLimiterFactory $rateLimiterFactory
) {
parent::__construct($context);
$this->languageServiceFactory = $languageServiceFactory;
$this->rateLimiterFactory = $rateLimiterFactory;
}
/**
......@@ -86,6 +97,8 @@ class BackendUserAuthenticator extends \TYPO3\CMS\Core\Middleware\BackendUserAut
// The global must be available very early, because methods below
// might trigger code which relies on it. See: #45625
$GLOBALS['BE_USER'] = GeneralUtility::makeInstance(BackendUserAuthentication::class);
// Rate Limiting
$rateLimiter = $this->ensureLoginRateLimit($GLOBALS['BE_USER'], $request);
try {
$GLOBALS['BE_USER']->start();
} catch (MfaRequiredException $mfaRequiredException) {
......@@ -122,6 +135,10 @@ class BackendUserAuthenticator extends \TYPO3\CMS\Core\Middleware\BackendUserAut
}
if ($this->context->getAspect('backend.user')->isLoggedIn()) {
$GLOBALS['BE_USER']->initializeBackendLogin();
// Reset the limiter after successful login
if ($rateLimiter) {
$rateLimiter->reset();
}
}
$GLOBALS['LANG'] = $this->languageServiceFactory->createFromUserPreferences($GLOBALS['BE_USER']);
// Re-setting the user and take the workspace from the user object now
......@@ -209,4 +226,26 @@ class BackendUserAuthenticator extends \TYPO3\CMS\Core\Middleware\BackendUserAut
{
return in_array($route->getPath(), $this->publicRoutes, true) === false;
}
protected function ensureLoginRateLimit(BackendUserAuthentication $user, ServerRequestInterface $request): ?LimiterInterface
{
if (!$user->isActiveLogin($request)) {
return null;
}
$loginRateLimiter = $this->rateLimiterFactory->createLoginRateLimiter($user, $request);
$limit = $loginRateLimiter->consume();
if ($limit && !$limit->isAccepted()) {
$this->logger->debug('Login request has been rate limited for IP address {ipAddress}', ['ipAddress' => $request->getAttribute('normalizedParams')->getRemoteAddress()]);
$dateformat = $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] . ' ' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'];
$lockedUntil = $limit->getRetryAfter()->getTimestamp() > 0 ?
' until ' . date($dateformat, $limit->getRetryAfter()->getTimestamp()) : '';
throw new RequestRateLimitedException(
HttpUtility::HTTP_STATUS_403,
'The login is locked' . $lockedUntil . ' due to too many failed login attempts from your IP address.',
'Login Request Rate Limited',
1616175867
);
}
return $loginRateLimiter;
}
}
......@@ -16,6 +16,7 @@
namespace TYPO3\CMS\Core\Authentication;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\HttpFoundation\Cookie;
......@@ -690,17 +691,8 @@ abstract class AbstractUserAuthentication implements LoggerAwareInterface
protected function handleLoginFailure(): void
{
$_params = [];
$sleep = true;
foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauth.php']['postLoginFailureProcessing'] ?? [] as $hookIdentifier => $_funcRef) {
GeneralUtility::callUserFunction($_funcRef, $_params, $this);
// This hack will be removed once this is migrated into PSR-14 Events
if ($hookIdentifier !== 'sendEmailOnFailedLoginAttempt') {
$sleep = false;
}
}
if ($sleep) {
// No hooks were triggered - default login failure behavior is to sleep 5 seconds
sleep(5);
}
}
......@@ -1131,6 +1123,12 @@ abstract class AbstractUserAuthentication implements LoggerAwareInterface
return $loginData;
}
public function isActiveLogin(ServerRequestInterface $request): bool
{
$status = $request->getParsedBody()[$this->formfield_status] ?? $request->getQueryParams()[$this->formfield_status] ?? '';
return $status === LoginType::LOGIN;
}
/**
* Processes Login data submitted by a form or params
*
......
......@@ -42,7 +42,9 @@ abstract class AbstractExceptionHandler implements ExceptionHandlerInterface, Si
private const IGNORED_EXCEPTION_CODES = [
1396795884, // Current host header value does not match the configured trusted hosts pattern
1581862822, // Failed HMAC validation due to modified __trustedProperties in extbase property mapping
1581862823 // Failed HMAC validation due to modified form state in ext:forms
1581862823, // Failed HMAC validation due to modified form state in ext:forms
1616175867, // Backend login request is rate limited
1616175847 // Frontend login request is rate limited
];
/**
......
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\RateLimiter;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\RateLimiter\LimiterInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory as SymfonyRateLimiterFactory;
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
use TYPO3\CMS\Core\Authentication\AbstractUserAuthentication;
use TYPO3\CMS\Core\Http\NormalizedParams;
use TYPO3\CMS\Core\RateLimiter\Storage\CachingFrameworkStorage;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* @internal This is not part of the official TYPO3 Core API due to a limitation of the experimental Symfony Rate Limiter API.
*/
class RateLimiterFactory
{
public function createLoginRateLimiter(AbstractUserAuthentication $userAuthentication, ServerRequestInterface $request): LimiterInterface
{
$loginType = $userAuthentication->loginType;
$normalizedParams = $request->getAttribute('normalizedParams') ?? NormalizedParams::createFromRequest($request);
$remoteIp = $normalizedParams->getRemoteAddress();
$limiterId = sha1('typo3-login-' . $loginType);
$limit = (int)($GLOBALS['TYPO3_CONF_VARS'][$loginType]['loginRateLimit'] ?? 5);
$interval = $GLOBALS['TYPO3_CONF_VARS'][$loginType]['loginRateLimitInterval'] ?? '15 minutes';
// If not enabled, return a null limiter
$enabled = !$this->isIpExcluded($loginType, $remoteIp) && $limit > 0;
$config = [
'id' => $limiterId,
'policy' => ($enabled ? 'sliding_window' : 'no_limit'),
'limit' => $limit,
'interval' => $interval,
];
$storage = ($enabled ? GeneralUtility::makeInstance(CachingFrameworkStorage::class) : new InMemoryStorage());
$limiterFactory = new SymfonyRateLimiterFactory(
$config,
$storage
);
return $limiterFactory->create($remoteIp);
}
protected function isIpExcluded(string $loginType, string $remoteAddress): bool
{
$ipMask = trim($GLOBALS['TYPO3_CONF_VARS'][$loginType]['loginRateLimitIpExcludeList'] ?? '');
return GeneralUtility::cmpIP($remoteAddress, $ipMask);
}
}
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\RateLimiter;
use TYPO3\CMS\Core\Error\Http\AbstractClientErrorException;
/**
* Exception thrown when a rate limiter has disallowed further processing
*/
class RequestRateLimitedException extends AbstractClientErrorException
{
}
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\RateLimiter\Storage;
use Symfony\Component\RateLimiter\LimiterStateInterface;
use Symfony\Component\RateLimiter\Policy\SlidingWindow;
use Symfony\Component\RateLimiter\Policy\TokenBucket;
use Symfony\Component\RateLimiter\Policy\Window;
use Symfony\Component\RateLimiter\Storage\StorageInterface;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
/**
* A rate limiter storage utilizing TYPO3's Caching Framework.
*
* @internal This is not part of the official TYPO3 Core API due to a limitation of the experimental Symfony Rate Limiter API.
*/
class CachingFrameworkStorage implements StorageInterface
{
private FrontendInterface $cacheInstance;
public function __construct(CacheManager $cacheInstance)
{