{"schema":"threatline.record/2","record":{"id":"GHSA-p2cj-r7vv-fr2r","aliases":["GHSA-p2cj-r7vv-fr2r"],"summary":"phpMyFAQ — Password-Reset Endpoints Have No Effective Rate Limiting","details":"**Summary:**\\\r\nThe 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).\r\n\r\n**Severity:**\\\r\nMedium — 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.\r\n\r\n**Affected Version:**\\\r\nphpMyFAQ 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.\r\n\r\n**Affected Component:**\\\r\nUnauthenticated password-reset REST API and the rate-limiting wiring.\r\n- `phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/UnauthorizedUserController.php`\r\n- `phpmyfaq/src/phpMyFAQ/EventListener/ApiRateLimiterListener.php`\r\n- `phpmyfaq/src/phpMyFAQ/Http/RateLimiter.php`\r\n- `phpmyfaq/src/phpMyFAQ/Controller/ContainerControllerResolver.php`\r\n- `phpmyfaq/src/services.php` (controller not registered)\r\n\r\n**Description:**\\\r\n`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.\r\n\r\nDefect 1 — in-memory limiter fallback:\r\n```php\r\n// UnauthorizedUserController.php\r\npublic function __construct(\r\n    // ...\r\n    ?RateLimiter $rateLimiter = null,          // line 70\r\n) {\r\n    // ...\r\n    $this->rateLimiter = $rateLimiter ?? new RateLimiter();   // line 75\r\n}\r\n\r\n#[Route(path: 'user/password/update', name: 'api.private.user.password', methods: ['PUT'])]   // line 84\r\npublic function requestReset(Request $request): JsonResponse\r\n{\r\n    if (!$this->rateLimiter->check(/* ... */)) { /* 429 */ }   // line 87 (and :107)\r\n\r\n#[Route(path: 'user/password/reset', name: 'api.private.user.password.reset', methods: ['POST'])]   // line 148\r\npublic function reset(Request $request): JsonResponse\r\n{\r\n    if (!$this->rateLimiter->check(/* ... */)) { /* 429 */ }   // line 151\r\n```\r\n```php\r\n// Http/RateLimiter.php:42 — no cache => InMemoryStorage (lives only for the current request)\r\n$this->storage = $storage ?? ($cache !== null ? new CacheStorage($cache) : new InMemoryStorage());\r\n```\r\n`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.)\r\n\r\nDefect 2 — global listener skips these routes:\r\n```php\r\n// EventListener/ApiRateLimiterListener.php\r\nif ($request->isMethodSafe()) { return; }          // :48-49\r\n$route = (string) $request->attributes->get('_route', '');\r\nif (str_starts_with($route, 'api.private.')) {     // :53\r\n    return;                                          // :54  -> no global per-IP limit\r\n}\r\n```\r\nBoth 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.\r\n\r\nNet effect: neither the per-controller limiter nor the global listener throttles these two unauthenticated endpoints.\r\n\r\n**Proof of Concept:**\\\r\nRequest-reset flooding (mail-bomb) against a known registered user:\r\n```bash\r\nfor i in $(seq 1 500); do\r\n  curl -s -o /dev/null -X PUT https://phpmyfaq.example/api/user/password/update \\\r\n    -H 'Content-Type: application/json' \\\r\n    -d '{\"username\":\"admin\",\"email\":\"admin@example.com\"}'\r\ndone\r\n# All 500 succeed (HTTP 200); no 429 is ever returned, and ~one reset email is sent per request.\r\n```\r\nUnlimited redemption attempts:\r\n```bash\r\nfor t in $(seq 1 100000); do\r\n  curl -s -o /dev/null -X POST https://phpmyfaq.example/api/user/password/reset \\\r\n    -H 'Content-Type: application/json' -d \"{\\\"token\\\":\\\"$t\\\",\\\"...\\\":\\\"...\\\"}\"\r\ndone\r\n# No throttling on the verify endpoint either.\r\n```\r\n\r\n**Steps To Reproduce:**\\\r\n1. On a test instance, create a user with a known username/email.\r\n2. Send many `PUT user/password/update` requests with that username+email in a loop.\r\n3. 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.\r\n4. Send many `POST user/password/reset` requests and observe no throttling.\r\n5. (Optional, to confirm root cause) Note that `services.php` contains no registration for `UnauthorizedUserController`, and that `ApiRateLimiterListener` returns early for `api.private.*` routes.\r\n\r\n**Impact:**\\\r\n- 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.\r\n- 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).\r\n\r\n**Root Causes:**\\\r\n1. **CWE-307 (Improper Restriction of Excessive Authentication Attempts) / CWE-770 (Allocation of Resources Without Limits):** the reset endpoints are effectively unthrottled.\r\n2. **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.\r\n3. **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.\r\n\r\n**Suggested Fix:**\\\r\n1. 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:\r\n```php\r\n// services.php\r\n$services->set(\\phpMyFAQ\\Controller\\Frontend\\Api\\UnauthorizedUserController::class)\r\n    ->args([/* ... */ service('phpmyfaq.http.rate-limiter')])\r\n    ->autowire();\r\n```\r\n2. 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.\r\n3. 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.\r\n4. Add a test asserting that exceeding the configured threshold on both `user/password/update` and `user/password/reset` returns HTTP 429.\r\n\r\n**References:**\\\r\n- CWE-307: Improper Restriction of Excessive Authentication Attempts\r\n- CWE-770: Allocation of Resources Without Limits or Throttling\r\n- CWE-799: Improper Control of Interaction Frequency\r\n- OWASP Top 10 2021 — A07: Identification and Authentication Failures\r\n- OWASP Authentication Cheat Sheet — Password Reset & Anti-automation\r\n- Symfony RateLimiter documentation — persistent storage (cache) vs `InMemoryStorage`\r\n","categories":["Unclassified"],"product":" phpMyFAQ","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-p2cj-r7vv-fr2r","source_modified_at":"2026-10-03T11:30:12Z","fetched_at":"2026-10-23T18:45:31.882637Z","package":{"ecosystem":"php","name":" phpMyFAQ"},"range":"<= 4.2.0-alpha.2"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-p2cj-r7vv-fr2r","source_modified_at":"2026-10-03T11:30:12Z","fetched_at":"2026-10-23T18:45:31.882637Z","package":{"ecosystem":"php","name":" phpMyFAQ"},"version":"4.2.0-beta"}],"cvss":6.5,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-03T11:30:12Z","modified_at":"2026-10-03T11:30:12Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-p2cj-r7vv-fr2r","fetched_at":"2026-10-23T18:45:31.882637Z","source_modified_at":"2026-10-03T11:30:12Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-03T11:30:12Z","content_hash":"be8b43ba8d01e094efef7050a31245b061a49555a840b54c3709f1bcce54d857"}}
