From 923e9d9f4d401a29830b7b44fe164895738bf83d Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Wed, 4 Dec 2024 14:10:05 +0100 Subject: [PATCH] Added rate limiter to password protection form --- CHANGELOG.md | 1 + Controller/ViewController.php | 4 ++-- DependencyInjection/CustomerPortalExtension.php | 11 +++++++++++ Service/ViewService.php | 14 ++++++++++++-- 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4175187..5ca8a73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Compatibility: requires minimum Kimai 2.25.0 +- Added rate limiter to password protection form (10 failures within 1-hour will block access) - Remove form target (password protection) to prevent proxy issues with http vs https - Removed md5 password from session key - Use non-deprecated API to fetch timesheets diff --git a/Controller/ViewController.php b/Controller/ViewController.php index 8173f51..b2a00d9 100644 --- a/Controller/ViewController.php +++ b/Controller/ViewController.php @@ -39,7 +39,7 @@ public function indexAction(#[MapEntity(mapping: ['shareKey' => 'shareKey'])] Sh $givenPassword = $request->get('spt-password'); // Check access. - if (!$this->viewService->hasAccess($sharedPortal, $givenPassword)) { + if (!$this->viewService->hasAccess($sharedPortal, $givenPassword, $request)) { return $this->render('@CustomerPortal/view/auth.html.twig', [ 'invalidPassword' => $request->isMethod('POST') && $givenPassword !== null, ]); @@ -66,7 +66,7 @@ public function viewCustomerProjectAction(#[MapEntity(mapping: ['shareKey' => 's throw $this->createAccessDeniedException('Requested project does not match customer'); } - if (!$this->viewService->hasAccess($sharedPortal, $givenPassword)) { + if (!$this->viewService->hasAccess($sharedPortal, $givenPassword, $request)) { return $this->render('@CustomerPortal/view/auth.html.twig', [ 'invalidPassword' => $request->isMethod('POST') && $givenPassword !== null, ]); diff --git a/DependencyInjection/CustomerPortalExtension.php b/DependencyInjection/CustomerPortalExtension.php index 655b8ee..405a74b 100644 --- a/DependencyInjection/CustomerPortalExtension.php +++ b/DependencyInjection/CustomerPortalExtension.php @@ -44,5 +44,16 @@ public function prepend(ContainerBuilder $container): void 'customer_portal' => 'auto', ], ]); + + $container->prependExtensionConfig('framework', [ + 'rate_limiter' => [ + 'customer_portal' => [ + 'policy' => 'fixed_window', + 'limit' => 10, + 'interval' => '1 hour', + 'lock_factory' => null, + ], + ], + ]); } } diff --git a/Service/ViewService.php b/Service/ViewService.php index 956849d..4563649 100644 --- a/Service/ViewService.php +++ b/Service/ViewService.php @@ -20,8 +20,10 @@ use KimaiPlugin\CustomerPortalBundle\Model\RecordMergeMode; use KimaiPlugin\CustomerPortalBundle\Model\TimeRecord; use KimaiPlugin\CustomerPortalBundle\Repository\SharedProjectTimesheetRepository; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\RateLimiter\RateLimiterFactory; class ViewService { @@ -30,6 +32,7 @@ public function __construct( private readonly RequestStack $request, private readonly PasswordHasherFactoryInterface $passwordHasherFactory, private readonly SharedProjectTimesheetRepository $sharedTimesheetRepository, + private readonly RateLimiterFactory $customerPortalLimiter ) { } @@ -37,7 +40,7 @@ public function __construct( /** * Check if the user has access to the given shared project timesheet. */ - public function hasAccess(SharedProjectTimesheet $sharedProject, ?string $givenPassword): bool + public function hasAccess(SharedProjectTimesheet $sharedProject, ?string $givenPassword, Request $request): bool { $hashedPassword = $sharedProject->getPassword(); @@ -47,11 +50,18 @@ public function hasAccess(SharedProjectTimesheet $sharedProject, ?string $givenP $sessionPasswordKey = \sprintf('spt-authed-%d-%s', $sharedProject->getId(), $shareKey); if (!$this->request->getSession()->has($sessionPasswordKey)) { + $limiter = $this->customerPortalLimiter->create($request->getClientIp()); + $limit = $limiter->consume(); + + if (!$limit->isAccepted()) { + return false; + } + // Check given password if ($givenPassword === null || $givenPassword === '' || !$this->passwordHasherFactory->getPasswordHasher('customer_portal')->verify($hashedPassword, $givenPassword)) { return false; } - + $limiter->reset(); $this->request->getSession()->set($sessionPasswordKey, true); } }