phpMyFAQ — Password-Reset Endpoints Have No Effective Rate Limiting
What fixes it
- 4.2.0-beta phpMyFAQ, phpGitHub
A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.
Description
Summary:\ The unauthenticated password-reset endpoints (PUT user/password/update to request a reset, and POST user/password/reset to redeem it) perform a rate-limit check, but the check is a no-op. Two independent defects compound: (1) UnauthorizedUserController is not registered as a DI service, so it is instantiated without the cache-backed rate limiter and falls back to an in-memory limiter whose storage is recreated on every request (so every request starts with an empty window and always passes); and (2) the global API rate-limit listener explicitly skips every route named api.private.*, which includes both reset routes. As a result there is no working throttle on either endpoint, allowing unauthenticated abuse (unlimited reset emails to any registered user — a mail-bomb / resource-exhaustion vector — and unlimited redemption attempts).
Severity:\ Medium — CVSS 3.1 Base ≈ 5.3 (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:L). Unauthenticated; the impact is abuse/DoS (mail flooding, mailer-reputation and resource exhaustion) rather than direct account takeover — the reset token itself is a CSPRNG HMAC keyed by the user's password hash and is not brute-forcible.
Affected Version:\ phpMyFAQ 4.2.x (verified on current development, commit 0fb7ecd); the 4.1.x branch should also be checked as the affected code pattern is not release-specific.
Affected Component:\ Unauthenticated password-reset REST API and the rate-limiting wiring.
phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/UnauthorizedUserController.phpphpmyfaq/src/phpMyFAQ/EventListener/ApiRateLimiterListener.phpphpmyfaq/src/phpMyFAQ/Http/RateLimiter.phpphpmyfaq/src/phpMyFAQ/Controller/ContainerControllerResolver.phpphpmyfaq/src/services.php(controller not registered)
Description:\ UnauthorizedUserController guards its reset actions with $this->rateLimiter->check(...), but the limiter it receives never persists state across requests, and the global limiter never runs for these routes.
Defect 1 — in-memory limiter fallback:
// UnauthorizedUserController.php
public function __construct(
// ...
?RateLimiter $rateLimiter = null, // line 70
) {
// ...
$this->rateLimiter = $rateLimiter ?? new RateLimiter(); // line 75
}
#[Route(path: 'user/password/update', name: 'api.private.user.password', methods: ['PUT'])] // line 84
public function requestReset(Request $request): JsonResponse
{
if (!$this->rateLimiter->check(/* ... */)) { /* 429 */ } // line 87 (and :107)
#[Route(path: 'user/password/reset', name: 'api.private.user.password.reset', methods: ['POST'])] // line 148
public function reset(Request $request): JsonResponse
{
if (!$this->rateLimiter->check(/* ... */)) { /* 429 */ } // line 151// Http/RateLimiter.php:42 — no cache => InMemoryStorage (lives only for the current request) $this->storage = $storage ?? ($cache !== null ? new CacheStorage($cache) : new InMemoryStorage());
UnauthorizedUserController is not registered in services.php (0 references), so ContainerControllerResolver instantiates it via new UnauthorizedUserController() with no arguments. $rateLimiter is therefore null → new RateLimiter() → constructed with no cache → InMemoryStorage. InMemoryStorage exists only for the lifetime of a single PHP request, so each incoming request begins with an empty rate-limit window and check() always returns "allowed". (The intended, DI-configured cache-backed limiter — phpmyfaq.http.rate-limiter — is never injected into this controller.)
Defect 2 — global listener skips these routes:
// EventListener/ApiRateLimiterListener.php
if ($request->isMethodSafe()) { return; } // :48-49
$route = (string) $request->attributes->get('_route', '');
if (str_starts_with($route, 'api.private.')) { // :53
return; // :54 -> no global per-IP limit
}Both reset routes are named api.private.user.password and api.private.user.password.reset, so the global per-IP API limiter also returns early and does not apply.
Net effect: neither the per-controller limiter nor the global listener throttles these two unauthenticated endpoints.
Proof of Concept:\ Request-reset flooding (mail-bomb) against a known registered user:
for i in $(seq 1 500); do
curl -s -o /dev/null -X PUT https://phpmyfaq.example/api/user/password/update \
-H 'Content-Type: application/json' \
-d '{"username":"admin","email":"admin@example.com"}'
done
# All 500 succeed (HTTP 200); no 429 is ever returned, and ~one reset email is sent per request.Unlimited redemption attempts:
for t in $(seq 1 100000); do
curl -s -o /dev/null -X POST https://phpmyfaq.example/api/user/password/reset \
-H 'Content-Type: application/json' -d "{\"token\":\"$t\",\"...\":\"...\"}"
done
# No throttling on the verify endpoint either.Steps To Reproduce:\
- On a test instance, create a user with a known username/email.
- Send many
PUT user/password/updaterequests with that username+email in a loop. - Observe that every request returns success (no HTTP 429) and a password-reset email is dispatched each time — i.e., unbounded mail sending toward the victim address.
- Send many
POST user/password/resetrequests and observe no throttling. - (Optional, to confirm root cause) Note that
services.phpcontains no registration forUnauthorizedUserController, and thatApiRateLimiterListenerreturns early forapi.private.*routes.
Impact:\
- Unauthenticated attacker can flood any registered user's inbox with password-reset emails (harassment, phishing cover, and damage to the sender domain's mail reputation), and consume mailer/server resources.
- Unlimited attempts against the redemption endpoint remove a defense-in-depth layer (direct token brute force remains infeasible because the token is an HMAC-SHA256 keyed by the secret password hash, and user enumeration is blunted by generic responses plus the required username+email match).
Root Causes:\
- CWE-307 (Improper Restriction of Excessive Authentication Attempts) / CWE-770 (Allocation of Resources Without Limits): the reset endpoints are effectively unthrottled.
- Misconfigured dependency wiring: the controller is not a registered service, so it receives an in-memory limiter (
InMemoryStorage) that resets every request instead of the persistent cache-backed limiter. - Over-broad exclusion: the global
ApiRateLimiterListenerskips allapi.private.*routes, but these particular routes are genuinely unauthenticated (the "private" prefix is a misnomer here), so they receive no global limit either.
Suggested Fix:\
- Register
UnauthorizedUserControllerinservices.phpand inject the persistent, cache-backed rate limiter (phpmyfaq.http.rate-limiter, backed byphpmyfaq.cache) socheck()state survives across requests:
// services.php
$services->set(\phpMyFAQ\Controller\Frontend\Api\UnauthorizedUserController::class)
->args([/* ... */ service('phpmyfaq.http.rate-limiter')])
->autowire();- Do not blanket-skip genuinely unauthenticated endpoints in
ApiRateLimiterListener— either rename these routes out of theapi.private.prefix, or add an explicit per-IP (and per-target-account) limiter for the reset issue/verify routes. - Ensure the per-account issuance limit throttles reset emails per target user (e.g., at most N reset mails per account per hour) in addition to per-IP throttling, so a single attacker rotating IPs cannot mail-bomb one victim.
- Add a test asserting that exceeding the configured threshold on both
user/password/updateanduser/password/resetreturns HTTP 429.
References:\
- CWE-307: Improper Restriction of Excessive Authentication Attempts
- CWE-770: Allocation of Resources Without Limits or Throttling
- CWE-799: Improper Control of Interaction Frequency
- OWASP Top 10 2021 — A07: Identification and Authentication Failures
- OWASP Authentication Cheat Sheet — Password Reset & Anti-automation
- Symfony RateLimiter documentation — persistent storage (cache) vs
InMemoryStorage
Text from GitHub Advisory Database
Affected versions 1
- phpMyFAQ, php<= 4.2.0-alpha.2GitHub