Add rate limiting to login and season-code entry points

Neither /login nor the public season-code guess form (POST /) had
any throttling, making both an unlimited brute-force/enumeration
oracle. Adds symfony/rate-limiter and enables login_throttling on
the main firewall, plus a dedicated per-IP rate limiter on the
season-code form.
This commit is contained in:
2026-07-13 09:56:39 +02:00
parent cd293aa86f
commit 89e7dd09ba
9 changed files with 169 additions and 8 deletions
+32
View File
@@ -11,6 +11,14 @@ use Tvdt\Controller\LoginController;
#[CoversClass(LoginController::class)]
final class LoginControllerTest extends AbstractControllerWebTestCase
{
protected function setUp(): void
{
parent::setUp();
// Login attempts are rate-limited; start each test with a clean quota.
self::getContainer()->get('cache.rate_limiter')->clear();
}
public function testLoginPageLoadsWhenNotAuthenticated(): void
{
$this->client->request(Request::METHOD_GET, '/login');
@@ -60,6 +68,30 @@ final class LoginControllerTest extends AbstractControllerWebTestCase
self::assertSelectorTextContains('body', 'Ongeldige inloggegevens.');
}
public function testLoginIsThrottledAfterTooManyFailedAttempts(): void
{
for ($i = 0; $i < 2; ++$i) {
$this->client->request(Request::METHOD_GET, '/login');
$form = $this->client->getCrawler()->filter('form')->form([
'_username' => 'test@example.org',
'_password' => 'wrong-password',
]);
$this->client->submit($form);
self::assertResponseRedirects('/login');
}
$this->client->request(Request::METHOD_GET, '/login');
$form = $this->client->getCrawler()->filter('form')->form([
'_username' => 'test@example.org',
'_password' => 'wrong-password',
]);
$this->client->submit($form);
self::assertResponseRedirects('/login');
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Te veel onjuiste inlogpogingen');
}
public function testLogoutIsInterceptedByFirewall(): void
{
$this->loginAs('test@example.org');
+28
View File
@@ -17,6 +17,14 @@ use Tvdt\Helpers\Base64;
#[CoversClass(QuizController::class)]
final class QuizControllerTest extends AbstractControllerWebTestCase
{
protected function setUp(): void
{
parent::setUp();
// Season-code guessing is rate-limited; start each test with a clean quota.
self::getContainer()->get('cache.rate_limiter')->clear();
}
private function answerQuestion(Question $question): void
{
$tomHash = Base64::base64UrlEncode('Tom');
@@ -69,6 +77,26 @@ final class QuizControllerTest extends AbstractControllerWebTestCase
self::assertResponseRedirects('/krtek');
}
public function testSelectSeasonIsThrottledAfterTooManyAttempts(): void
{
for ($i = 0; $i < 3; ++$i) {
$crawler = $this->client->request(Request::METHOD_GET, '/');
$form = $crawler->filter('form')->form([
'select_season[season_code]' => 'aaaaa',
]);
$this->client->submit($form);
self::assertResponseRedirects('/');
}
$crawler = $this->client->request(Request::METHOD_GET, '/');
$form = $crawler->filter('form')->form([
'select_season[season_code]' => 'aaaaa',
]);
$this->client->submit($form);
self::assertResponseStatusCodeSame(429);
}
public function testEnterNamePageLoads(): void
{
$this->client->request(Request::METHOD_GET, '/krtek');