CVE-2025-9824
Description
ImpactThe attacker can validate if a user exists by checking the time login returns. This timing difference can be used to enumerate valid usernames, after which an attacker could attempt brute force attacks.
PatchesThis vulnerability has been patched, implementing a timing-safe form login authenticator that ensures consistent response times regardless of whether a user exists or not.
Technical DetailsThe vulnerability was caused by different response times when:
- A valid username was provided (password hashing occurred)
- An invalid username was provided (no password hashing occurred)
The fix introduces a TimingSafeFormLoginAuthenticator that performs a dummy password hash verification even for non-existent users, ensuring consistent timing.
WorkaroundsNo workarounds are available. Users should upgrade to the patched version.
References * https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/03-Identity_Management_Testing/04-Testing_for_Account_Enumeration_and_Guessable_User_Account
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
mautic/corePackagist | >= 4.4.0, < 4.4.17 | 4.4.17 |
mautic/corePackagist | >= 5.0.0-alpha, < 5.2.8 | 5.2.8 |
mautic/corePackagist | >= 6.0.0-alpha, < 6.0.5 | 6.0.5 |
Affected products
1Patches
25 files changed · +320 −0
app/bundles/UserBundle/Config/services.php+10 −0 modified@@ -73,4 +73,14 @@ $services->alias(LightSaml\SymfonyBridgeBundle\Bridge\Container\BuildContainer::class, 'lightsaml.container.build'); $services->load('LightSaml\\SpBundle\\Controller\\', '%kernel.project_dir%/vendor/javer/sp-bundle/src/LightSaml/SpBundle/Controller/*.php') ->tag('controller.service_arguments'); + // Decorate the form_login class to ensure no user enumeration can + // happen via timing attacks. + $services->set('mautic.security.authenticator.form_login.decorator', Mautic\UserBundle\Security\TimingSafeFormLoginAuthenticator::class) + ->decorate('security.authenticator.form_login.main') + ->args([ + service('.inner'), + service('mautic.user.provider'), + service('security.password_hasher_factory'), + [], // This will be replaced by the compiler pass + ]); };
app/bundles/UserBundle/DependencyInjection/Compiler/FormLoginAuthenticatorOptionsPass.php+31 −0 added@@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +namespace Mautic\UserBundle\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +class FormLoginAuthenticatorOptionsPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + if (!$container->has('mautic.security.authenticator.form_login.decorator')) { + return; + } + + $decoratedServiceId = 'mautic.security.authenticator.form_login.decorator.inner'; + if (!$container->has($decoratedServiceId)) { + return; + } + + $decoratedService = $container->getDefinition($decoratedServiceId); + // Grab the options from the original definition + $options = $decoratedService->getArgument(4); + + $decorator = $container->getDefinition('mautic.security.authenticator.form_login.decorator'); + // Set the options for our decorated service + $decorator->replaceArgument(3, $options); + } +}
app/bundles/UserBundle/MauticUserBundle.php+2 −0 modified@@ -2,6 +2,7 @@ namespace Mautic\UserBundle; +use Mautic\UserBundle\DependencyInjection\Compiler\FormLoginAuthenticatorOptionsPass; use Mautic\UserBundle\DependencyInjection\Compiler\OAuthReplacePass; use Mautic\UserBundle\DependencyInjection\Compiler\SsoAuthenticatorPass; use Mautic\UserBundle\DependencyInjection\Firewall\Factory\MauticSsoFactory; @@ -24,5 +25,6 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new OAuthReplacePass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); $container->addCompilerPass(new SsoAuthenticatorPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); + $container->addCompilerPass(new FormLoginAuthenticatorOptionsPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); } }
app/bundles/UserBundle/Security/TimingSafeFormLoginAuthenticator.php+156 −0 added@@ -0,0 +1,156 @@ +<?php + +declare(strict_types=1); + +namespace Mautic\UserBundle\Security; + +use Mautic\UserBundle\Entity\User; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\UserNotFoundException; +use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\FormLoginAuthenticator; +use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; +use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; +use Symfony\Component\Security\Http\SecurityRequestAttributes; + +class TimingSafeFormLoginAuthenticator implements AuthenticatorInterface, AuthenticationEntryPointInterface, InteractiveAuthenticatorInterface +{ + /** + * @var array<mixed> + */ + private array $options; + + /** + * @param array<mixed> $options + */ + public function __construct(private FormLoginAuthenticator $authenticator, private UserProviderInterface $userProvider, private PasswordHasherFactoryInterface $passwordHasherFactory, array $options) + { + $this->authenticator = $authenticator; + $this->userProvider = $userProvider; + $this->passwordHasherFactory = $passwordHasherFactory; + $this->options = array_merge([ + 'username_parameter' => '_username', + 'password_parameter' => '_password', + 'check_path' => '/login_check', + 'post_only' => true, + 'form_only' => false, + 'enable_csrf' => false, + 'csrf_parameter' => '_csrf_token', + 'csrf_token_id' => 'authenticate', + ], $options); + } + + public function supports(Request $request): ?bool + { + return $this->authenticator->supports($request); + } + + public function authenticate(Request $request): Passport + { + $credentials = $this->getCredentials($request); + $passwordHasherFactory = $this->passwordHasherFactory; + $userLoader = function (string $identifier) use ($passwordHasherFactory, $credentials): UserInterface { + try { + // Attempt to load the real user. + return $this->userProvider->loadUserByIdentifier($identifier); + } catch (UserNotFoundException $e) { + // If real user is not found, provide a dummy user and still 'check' the credentials to prevent + // user enumeration via response timing comparison. + // We check it against a pre-calculated hash so the verify functions take roughly + // the same amount of time, and we pass the actual entered password so the response + // timing varies with the given password the same way it does for existing users. + $user = new User(); + $passwordHasherFactory->getPasswordHasher($user)->verify('$2y$13$aAwXNyqA87lcXQQuk8Cp6eo2amRywLct29oG2uWZ8lYBeamFZ8UhK', $credentials['password']); + // Rethrow exception + throw $e; + } + }; + + $userBadge = new UserBadge($credentials['username'], $userLoader); + $passport = new Passport($userBadge, new PasswordCredentials($credentials['password']), [new RememberMeBadge()]); + + if ($this->options['enable_csrf']) { + $passport->addBadge(new CsrfTokenBadge($this->options['csrf_token_id'], $credentials['csrf_token'])); + } + + if ($this->userProvider instanceof PasswordUpgraderInterface) { + $passport->addBadge(new PasswordUpgradeBadge($credentials['password'], $this->userProvider)); + } + + return $passport; + } + + public function createToken(Passport $passport, string $firewallName): TokenInterface + { + return $this->authenticator->createToken($passport, $firewallName); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return $this->authenticator->onAuthenticationSuccess($request, $token, $firewallName); + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + return $this->authenticator->onAuthenticationFailure($request, $exception); + } + + public function start(Request $request, ?AuthenticationException $authException = null): Response + { + return $this->authenticator->start($request, $authException); + } + + public function isInteractive(): bool + { + return $this->authenticator->isInteractive(); + } + + /** + * @return array<mixed> + */ + private function getCredentials(Request $request): array + { + $credentials = []; + $credentials['csrf_token'] = $request->get($this->options['csrf_parameter']); + + if ($this->options['post_only']) { + $credentials['username'] = $request->request->get($this->options['username_parameter']); + $credentials['password'] = $request->request->get($this->options['password_parameter'], ''); + } else { + $credentials['username'] = $request->get($this->options['username_parameter']); + $credentials['password'] = $request->get($this->options['password_parameter'], ''); + } + + if (!\is_string($credentials['username']) && !$credentials['username'] instanceof \Stringable) { + throw new BadRequestHttpException(sprintf('The key "%s" must be a string, "%s" given.', $this->options['username_parameter'], \gettype($credentials['username']))); + } + + $credentials['username'] = trim($credentials['username']); + + $request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $credentials['username']); + + if (!\is_string($credentials['password']) && (!\is_object($credentials['password']) || !method_exists($credentials['password'], '__toString'))) { + throw new BadRequestHttpException(sprintf('The key "%s" must be a string, "%s" given.', $this->options['password_parameter'], \gettype($credentials['password']))); + } + + if (!\is_string($credentials['csrf_token'] ?? '') && (!\is_object($credentials['csrf_token']) || !method_exists($credentials['csrf_token'], '__toString'))) { + throw new BadRequestHttpException(sprintf('The key "%s" must be a string, "%s" given.', $this->options['csrf_parameter'], \gettype($credentials['csrf_token']))); + } + + return $credentials; + } +}
app/bundles/UserBundle/Tests/Security/Authenticator/TimingSafeFormLoginAuthenticatorTest.php+121 −0 added@@ -0,0 +1,121 @@ +<?php + +declare(strict_types=1); + +namespace Mautic\UserBundle\Tests\Security\Authenticator; + +use Mautic\UserBundle\Entity\User; +use Mautic\UserBundle\Security\TimingSafeFormLoginAuthenticator; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; +use Symfony\Component\Security\Core\Exception\UserNotFoundException; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Http\Authenticator\FormLoginAuthenticator; + +class TimingSafeFormLoginAuthenticatorTest extends TestCase +{ + /** + * @return array<mixed> + */ + private function getCredentials(TimingSafeFormLoginAuthenticator $authenticator, Request $request): array + { + $method = new \ReflectionMethod(TimingSafeFormLoginAuthenticator::class, 'getCredentials'); + $method->setAccessible(true); + + return $method->invoke($authenticator, $request); + } + + public function testAuthenticateWithExistingUser(): void + { + $request = new Request([], ['username' => 'testuser', 'password' => 'password']); + $request->setSession(new Session(new MockArraySessionStorage())); + $user = new User(); + $user->setUsername('testuser'); + + /** @var UserProviderInterface|\PHPUnit\Framework\MockObject\MockObject $userProvider */ + $userProvider = $this->createMock(UserProviderInterface::class); + $userProvider->expects($this->once()) + ->method('loadUserByIdentifier') + ->with('testuser') + ->willReturn($user); + + $passwordHasher = $this->createMock(PasswordHasherInterface::class); + /** @var PasswordHasherFactoryInterface|\PHPUnit\Framework\MockObject\MockObject $passwordHasherFactory */ + $passwordHasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); + $passwordHasherFactory->expects($this->never()) + ->method('getPasswordHasher'); + + /** @var FormLoginAuthenticator|\PHPUnit\Framework\MockObject\MockObject $formLoginAuthenticator */ + $formLoginAuthenticator = $this->createMock(FormLoginAuthenticator::class); + + $authenticator = new TimingSafeFormLoginAuthenticator( + $formLoginAuthenticator, + $userProvider, + $passwordHasherFactory, + [ + 'enable_csrf' => false, + 'username_parameter' => 'username', + 'password_parameter' => 'password', + 'csrf_parameter' => '_csrf_token', + 'post_only' => true, + ] + ); + + $credentials = $this->getCredentials($authenticator, $request); + $this->assertEquals('testuser', $credentials['username']); + $this->assertEquals('password', $credentials['password']); + + $passport = $authenticator->authenticate($request); + $passport->getUser(); + } + + public function testAuthenticateWithNonExistingUser(): void + { + $this->expectException(UserNotFoundException::class); + + $request = new Request([], ['username' => 'testuser', 'password' => 'password']); + $request->setSession(new Session(new MockArraySessionStorage())); + + /** @var UserProviderInterface|\PHPUnit\Framework\MockObject\MockObject $userProvider */ + $userProvider = $this->createMock(UserProviderInterface::class); + $userProvider->expects($this->once()) + ->method('loadUserByIdentifier') + ->with('testuser') + ->willThrowException(new UserNotFoundException()); + + /** @var PasswordHasherInterface|\PHPUnit\Framework\MockObject\MockObject $passwordHasher */ + $passwordHasher = $this->createMock(PasswordHasherInterface::class); + $passwordHasher->expects($this->once()) + ->method('verify') + ->with('$2y$13$aAwXNyqA87lcXQQuk8Cp6eo2amRywLct29oG2uWZ8lYBeamFZ8UhK', 'password'); + + /** @var PasswordHasherFactoryInterface|\PHPUnit\Framework\MockObject\MockObject $passwordHasherFactory */ + $passwordHasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); + $passwordHasherFactory->expects($this->once()) + ->method('getPasswordHasher') + ->willReturn($passwordHasher); + + /** @var FormLoginAuthenticator|\PHPUnit\Framework\MockObject\MockObject $formLoginAuthenticator */ + $formLoginAuthenticator = $this->createMock(FormLoginAuthenticator::class); + + $authenticator = new TimingSafeFormLoginAuthenticator( + $formLoginAuthenticator, + $userProvider, + $passwordHasherFactory, + [ + 'enable_csrf' => false, + 'username_parameter' => 'username', + 'password_parameter' => 'password', + 'csrf_parameter' => '_csrf_token', + 'post_only' => true, + ] + ); + + $passport = $authenticator->authenticate($request); + $passport->getUser(); + } +}
b4264c717ce3MST-46 - Prevent user enumeration
5 files changed · +320 −0
app/bundles/UserBundle/Config/services.php+10 −0 modified@@ -73,4 +73,14 @@ $services->alias(LightSaml\SymfonyBridgeBundle\Bridge\Container\BuildContainer::class, 'lightsaml.container.build'); $services->load('LightSaml\\SpBundle\\Controller\\', '%kernel.project_dir%/vendor/javer/sp-bundle/src/LightSaml/SpBundle/Controller/*.php') ->tag('controller.service_arguments'); + // Decorate the form_login class to ensure no user enumeration can + // happen via timing attacks. + $services->set('mautic.security.authenticator.form_login.decorator', Mautic\UserBundle\Security\TimingSafeFormLoginAuthenticator::class) + ->decorate('security.authenticator.form_login.main') + ->args([ + service('.inner'), + service('mautic.user.provider'), + service('security.password_hasher_factory'), + [], // This will be replaced by the compiler pass + ]); };
app/bundles/UserBundle/DependencyInjection/Compiler/FormLoginAuthenticatorOptionsPass.php+31 −0 added@@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +namespace Mautic\UserBundle\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +class FormLoginAuthenticatorOptionsPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + if (!$container->has('mautic.security.authenticator.form_login.decorator')) { + return; + } + + $decoratedServiceId = 'mautic.security.authenticator.form_login.decorator.inner'; + if (!$container->has($decoratedServiceId)) { + return; + } + + $decoratedService = $container->getDefinition($decoratedServiceId); + // Grab the options from the original definition + $options = $decoratedService->getArgument(4); + + $decorator = $container->getDefinition('mautic.security.authenticator.form_login.decorator'); + // Set the options for our decorated service + $decorator->replaceArgument(3, $options); + } +}
app/bundles/UserBundle/MauticUserBundle.php+2 −0 modified@@ -2,6 +2,7 @@ namespace Mautic\UserBundle; +use Mautic\UserBundle\DependencyInjection\Compiler\FormLoginAuthenticatorOptionsPass; use Mautic\UserBundle\DependencyInjection\Compiler\OAuthReplacePass; use Mautic\UserBundle\DependencyInjection\Compiler\SsoAuthenticatorPass; use Mautic\UserBundle\DependencyInjection\Firewall\Factory\MauticSsoFactory; @@ -24,5 +25,6 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new OAuthReplacePass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); $container->addCompilerPass(new SsoAuthenticatorPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); + $container->addCompilerPass(new FormLoginAuthenticatorOptionsPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); } }
app/bundles/UserBundle/Security/TimingSafeFormLoginAuthenticator.php+156 −0 added@@ -0,0 +1,156 @@ +<?php + +declare(strict_types=1); + +namespace Mautic\UserBundle\Security; + +use Mautic\UserBundle\Entity\User; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Core\Exception\UserNotFoundException; +use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\FormLoginAuthenticator; +use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\PasswordUpgradeBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials; +use Symfony\Component\Security\Http\Authenticator\Passport\Passport; +use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; +use Symfony\Component\Security\Http\SecurityRequestAttributes; + +class TimingSafeFormLoginAuthenticator implements AuthenticatorInterface, AuthenticationEntryPointInterface, InteractiveAuthenticatorInterface +{ + /** + * @var array<mixed> + */ + private array $options; + + /** + * @param array<mixed> $options + */ + public function __construct(private FormLoginAuthenticator $authenticator, private UserProviderInterface $userProvider, private PasswordHasherFactoryInterface $passwordHasherFactory, array $options) + { + $this->authenticator = $authenticator; + $this->userProvider = $userProvider; + $this->passwordHasherFactory = $passwordHasherFactory; + $this->options = array_merge([ + 'username_parameter' => '_username', + 'password_parameter' => '_password', + 'check_path' => '/login_check', + 'post_only' => true, + 'form_only' => false, + 'enable_csrf' => false, + 'csrf_parameter' => '_csrf_token', + 'csrf_token_id' => 'authenticate', + ], $options); + } + + public function supports(Request $request): ?bool + { + return $this->authenticator->supports($request); + } + + public function authenticate(Request $request): Passport + { + $credentials = $this->getCredentials($request); + $passwordHasherFactory = $this->passwordHasherFactory; + $userLoader = function (string $identifier) use ($passwordHasherFactory, $credentials): UserInterface { + try { + // Attempt to load the real user. + return $this->userProvider->loadUserByIdentifier($identifier); + } catch (UserNotFoundException $e) { + // If real user is not found, provide a dummy user and still 'check' the credentials to prevent + // user enumeration via response timing comparison. + // We check it against a pre-calculated hash so the verify functions take roughly + // the same amount of time, and we pass the actual entered password so the response + // timing varies with the given password the same way it does for existing users. + $user = new User(); + $passwordHasherFactory->getPasswordHasher($user)->verify('$2y$13$aAwXNyqA87lcXQQuk8Cp6eo2amRywLct29oG2uWZ8lYBeamFZ8UhK', $credentials['password']); + // Rethrow exception + throw $e; + } + }; + + $userBadge = new UserBadge($credentials['username'], $userLoader); + $passport = new Passport($userBadge, new PasswordCredentials($credentials['password']), [new RememberMeBadge()]); + + if ($this->options['enable_csrf']) { + $passport->addBadge(new CsrfTokenBadge($this->options['csrf_token_id'], $credentials['csrf_token'])); + } + + if ($this->userProvider instanceof PasswordUpgraderInterface) { + $passport->addBadge(new PasswordUpgradeBadge($credentials['password'], $this->userProvider)); + } + + return $passport; + } + + public function createToken(Passport $passport, string $firewallName): TokenInterface + { + return $this->authenticator->createToken($passport, $firewallName); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return $this->authenticator->onAuthenticationSuccess($request, $token, $firewallName); + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + return $this->authenticator->onAuthenticationFailure($request, $exception); + } + + public function start(Request $request, AuthenticationException $authException = null): Response + { + return $this->authenticator->start($request, $authException); + } + + public function isInteractive(): bool + { + return $this->authenticator->isInteractive(); + } + + /** + * @return array<mixed> + */ + private function getCredentials(Request $request): array + { + $credentials = []; + $credentials['csrf_token'] = $request->get($this->options['csrf_parameter']); + + if ($this->options['post_only']) { + $credentials['username'] = $request->request->get($this->options['username_parameter']); + $credentials['password'] = $request->request->get($this->options['password_parameter'], ''); + } else { + $credentials['username'] = $request->get($this->options['username_parameter']); + $credentials['password'] = $request->get($this->options['password_parameter'], ''); + } + + if (!\is_string($credentials['username']) && !$credentials['username'] instanceof \Stringable) { + throw new BadRequestHttpException(sprintf('The key "%s" must be a string, "%s" given.', $this->options['username_parameter'], \gettype($credentials['username']))); + } + + $credentials['username'] = trim($credentials['username']); + + $request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $credentials['username']); + + if (!\is_string($credentials['password']) && (!\is_object($credentials['password']) || !method_exists($credentials['password'], '__toString'))) { + throw new BadRequestHttpException(sprintf('The key "%s" must be a string, "%s" given.', $this->options['password_parameter'], \gettype($credentials['password']))); + } + + if (!\is_string($credentials['csrf_token'] ?? '') && (!\is_object($credentials['csrf_token']) || !method_exists($credentials['csrf_token'], '__toString'))) { + throw new BadRequestHttpException(sprintf('The key "%s" must be a string, "%s" given.', $this->options['csrf_parameter'], \gettype($credentials['csrf_token']))); + } + + return $credentials; + } +}
app/bundles/UserBundle/Tests/Security/Authenticator/TimingSafeFormLoginAuthenticatorTest.php+121 −0 added@@ -0,0 +1,121 @@ +<?php + +declare(strict_types=1); + +namespace Mautic\UserBundle\Tests\Security\Authenticator; + +use Mautic\UserBundle\Entity\User; +use Mautic\UserBundle\Security\TimingSafeFormLoginAuthenticator; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; +use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactoryInterface; +use Symfony\Component\PasswordHasher\PasswordHasherInterface; +use Symfony\Component\Security\Core\Exception\UserNotFoundException; +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Http\Authenticator\FormLoginAuthenticator; + +class TimingSafeFormLoginAuthenticatorTest extends TestCase +{ + /** + * @return array<mixed> + */ + private function getCredentials(TimingSafeFormLoginAuthenticator $authenticator, Request $request): array + { + $method = new \ReflectionMethod(TimingSafeFormLoginAuthenticator::class, 'getCredentials'); + $method->setAccessible(true); + + return $method->invoke($authenticator, $request); + } + + public function testAuthenticateWithExistingUser(): void + { + $request = new Request([], ['username' => 'testuser', 'password' => 'password']); + $request->setSession(new Session(new MockArraySessionStorage())); + $user = new User(); + $user->setUsername('testuser'); + + /** @var UserProviderInterface|\PHPUnit\Framework\MockObject\MockObject $userProvider */ + $userProvider = $this->createMock(UserProviderInterface::class); + $userProvider->expects($this->once()) + ->method('loadUserByIdentifier') + ->with('testuser') + ->willReturn($user); + + $passwordHasher = $this->createMock(PasswordHasherInterface::class); + /** @var PasswordHasherFactoryInterface|\PHPUnit\Framework\MockObject\MockObject $passwordHasherFactory */ + $passwordHasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); + $passwordHasherFactory->expects($this->never()) + ->method('getPasswordHasher'); + + /** @var FormLoginAuthenticator|\PHPUnit\Framework\MockObject\MockObject $formLoginAuthenticator */ + $formLoginAuthenticator = $this->createMock(FormLoginAuthenticator::class); + + $authenticator = new TimingSafeFormLoginAuthenticator( + $formLoginAuthenticator, + $userProvider, + $passwordHasherFactory, + [ + 'enable_csrf' => false, + 'username_parameter' => 'username', + 'password_parameter' => 'password', + 'csrf_parameter' => '_csrf_token', + 'post_only' => true, + ] + ); + + $credentials = $this->getCredentials($authenticator, $request); + $this->assertEquals('testuser', $credentials['username']); + $this->assertEquals('password', $credentials['password']); + + $passport = $authenticator->authenticate($request); + $passport->getUser(); + } + + public function testAuthenticateWithNonExistingUser(): void + { + $this->expectException(UserNotFoundException::class); + + $request = new Request([], ['username' => 'testuser', 'password' => 'password']); + $request->setSession(new Session(new MockArraySessionStorage())); + + /** @var UserProviderInterface|\PHPUnit\Framework\MockObject\MockObject $userProvider */ + $userProvider = $this->createMock(UserProviderInterface::class); + $userProvider->expects($this->once()) + ->method('loadUserByIdentifier') + ->with('testuser') + ->willThrowException(new UserNotFoundException()); + + /** @var PasswordHasherInterface|\PHPUnit\Framework\MockObject\MockObject $passwordHasher */ + $passwordHasher = $this->createMock(PasswordHasherInterface::class); + $passwordHasher->expects($this->once()) + ->method('verify') + ->with('$2y$13$aAwXNyqA87lcXQQuk8Cp6eo2amRywLct29oG2uWZ8lYBeamFZ8UhK', 'password'); + + /** @var PasswordHasherFactoryInterface|\PHPUnit\Framework\MockObject\MockObject $passwordHasherFactory */ + $passwordHasherFactory = $this->createMock(PasswordHasherFactoryInterface::class); + $passwordHasherFactory->expects($this->once()) + ->method('getPasswordHasher') + ->willReturn($passwordHasher); + + /** @var FormLoginAuthenticator|\PHPUnit\Framework\MockObject\MockObject $formLoginAuthenticator */ + $formLoginAuthenticator = $this->createMock(FormLoginAuthenticator::class); + + $authenticator = new TimingSafeFormLoginAuthenticator( + $formLoginAuthenticator, + $userProvider, + $passwordHasherFactory, + [ + 'enable_csrf' => false, + 'username_parameter' => 'username', + 'password_parameter' => 'password', + 'csrf_parameter' => '_csrf_token', + 'post_only' => true, + ] + ); + + $passport = $authenticator->authenticate($request); + $passport->getUser(); + } +}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-3ggv-qwcp-j6xgghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-9824ghsaADVISORY
- github.com/mautic/mautic/commit/6bc4f5f1aabb13df12714ad0ea9fc281cbb867c6ghsaWEB
- github.com/mautic/mautic/commit/b4264c717ce31fbafafcefc04b02ecb9fb911e62ghsaWEB
- github.com/mautic/mautic/security/advisories/GHSA-3ggv-qwcp-j6xgnvdWEB
News mentions
0No linked articles in our index yet.