All vulnerabilities
GHSA-p2cj-r7vv-fr2r

phpMyFAQ — Password-Reset Endpoints Have No Effective Rate Limiting

phpMyFAQ
CVSS
6.5Medium
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed in4.2.0-betaFixed versionGitHub
Published3 Oct 2026Updated 3 Oct 2026GitHub

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.php
  • phpmyfaq/src/phpMyFAQ/EventListener/ApiRateLimiterListener.php
  • phpmyfaq/src/phpMyFAQ/Http/RateLimiter.php
  • phpmyfaq/src/phpMyFAQ/Controller/ContainerControllerResolver.php
  • phpmyfaq/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:\

  1. On a test instance, create a user with a known username/email.
  2. Send many PUT user/password/update requests with that username+email in a loop.
  3. 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.
  4. Send many POST user/password/reset requests and observe no throttling.
  5. (Optional, to confirm root cause) Note that services.php contains no registration for UnauthorizedUserController, and that ApiRateLimiterListener returns early for api.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:\

  1. CWE-307 (Improper Restriction of Excessive Authentication Attempts) / CWE-770 (Allocation of Resources Without Limits): the reset endpoints are effectively unthrottled.
  2. 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.
  3. Over-broad exclusion: the global ApiRateLimiterListener skips all api.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:\

  1. Register UnauthorizedUserController in services.php and inject the persistent, cache-backed rate limiter (phpmyfaq.http.rate-limiter, backed by phpmyfaq.cache) so check() state survives across requests:
// services.php
$services->set(\phpMyFAQ\Controller\Frontend\Api\UnauthorizedUserController::class)
    ->args([/* ... */ service('phpmyfaq.http.rate-limiter')])
    ->autowire();
  1. Do not blanket-skip genuinely unauthenticated endpoints in ApiRateLimiterListener — either rename these routes out of the api.private. prefix, or add an explicit per-IP (and per-target-account) limiter for the reset issue/verify routes.
  2. 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.
  3. Add a test asserting that exceeding the configured threshold on both user/password/update and user/password/reset returns 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