Write-up: Stored XSS in phpMyFAQ via an html_entity_decode() sanitizer bypass (High, SA-2026-10-03)

In short. phpMyFAQ sanitized a guest-submitted FAQ answer by encoding the dangerous characters and then, on the very next line, decoded them again with nothing to clean up afterwards. The raw markup is stored and runs as script in the administrator's browser the moment they open the submission in the editor. Fixed in 4.1.9, rated High.

One answer field, two lines of code LIVE INERT LIVE <img src=x onerror=confirm(/XSS/)> &lt;img src=x onerror=confirm(/XSS/)&gt; <img src=x onerror=confirm(/XSS/)> 1. A guest submits this in the FAQ answer field 2. FILTER_SANITIZE_SPECIAL_CHARS encodes it, now harmless 3. html_entity_decode() turns it back, and it is stored as-is No sanitizer runs between step 2 and storage.
The sanitizer encodes the special characters, then the next line decodes them again. What gets stored is the original, live markup.

Background

phpMyFAQ is a popular open-source FAQ and knowledge-base application. It lets site operators accept FAQ suggestions from anonymous visitors, which an administrator later reviews in the backend editor. That review step is the moment this bug turns an anonymous text field into code running with administrator privileges.

Two settings have to be on, and both are ordinary options rather than developer-only toggles.

preconditions
main.enableWysiwygEditorFrontend = enabled
records.allowNewFaqsForGuests    = enabled

The vulnerable code

The frontend controller sanitizes the submitted answer and then immediately undoes the sanitization.

src/phpMyFAQ/Controller/Frontend/FaqController.php:72-73
$answer = Filter::filterVar($data->answer, FILTER_SANITIZE_SPECIAL_CHARS); // encodes < > " '
$answer = trim(html_entity_decode((string) $answer));                      // decodes them back, nothing after

The first line encodes <, >, " and ' into HTML entities, which is exactly what you want. The second line calls html_entity_decode() on the result, which converts those entities straight back into the raw characters. Nothing sanitizes the value after the decode, so the original markup is what gets stored.

Why it executes: raw output into a textarea

The stored answer is later written into the admin editor with Twig's raw filter, which disables output escaping.

assets/templates/admin/content/faq.editor.twig:119
<textarea ...>{{ faqData['content'] | raw }}</textarea>

A literal </textarea> inside the stored value closes the textarea early. Everything after it is parsed as HTML inside the editor's contenteditable area, so an <img> with an onerror handler becomes a live DOM node and fires on load.

Admin FAQ editor, content printed with | raw <textarea> </textarea><img src=x onerror=confirm(/XSS/)> </textarea> </textarea><img src=x onerror=confirm(/XSS/)> field closes here </textarea> <img onerror=...> live DOM node localhost says /XSS/ OK 1. The stored value lands inside the textarea 2. Its </textarea> closes the field early 3. The <img> becomes a live node and onerror runs
The payload's own closing tag ends the textarea, so the image tag after it is treated as real HTML and its onerror handler executes in the admin editor.

Who ends up running it

The input is anonymous, but the execution is not. The code runs later, in the authenticated administrator's session, when they open the suggestion to review it.

Anonymous input, administrator execution Anonymous visitor submits a FAQ suggestion Database stored unsanitized Administrator opens it in the editor confirm(/XSS/) runs as admin
The attacker never authenticates. The stored payload executes in the administrator's browser during review.

Proof of concept

As an anonymous visitor, submit a FAQ suggestion and put this in the answer field.

answer field
</textarea><img src=x style="display:none" onerror=confirm(/XSS/)>

When an administrator opens the submission in the FAQ editor, confirm(/XSS/) fires on load with no interaction needed. In DevTools the payload is present as a live DOM node, not as escaped text.

The fix

The project already had the right helper and was using it for the equivalent field in the API controllers. The frontend controller needed to call it too, instead of encoding and then decoding.

src/phpMyFAQ/Filter.php:148-157 (already used in the API path)
$answer = Filter::filterHtml($data->answer);

The maintainer's fix is in commit 23932644.

Impact and severity

An unauthenticated attacker stores script that executes in an administrator's authenticated session, which is enough to act as that administrator through the backend. The maintainer rated it High.

rating
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N  ->  8.1 (High)
CWE-79 (contributing: CWE-116)

Note on scope

The issue requires main.enableWysiwygEditorFrontend to be enabled. That is a supported option rather than a developer-only setting, and the vendor's own advisory from 20 August 2026 scored an identical precondition as High, so the configuration dependency does not lower the rating here.

A note on duplicates

phpMyFAQ has fixed the same encode-then-decode pattern in other files before, tracked as GHSA-f5p7-2c9q-8896, GHSA-pqh6-8fxf-jx22 and CVE-2025-68951. This report covers a separate instance of that pattern in the frontend FAQ controller.

Disclosure

I reported this to the phpMyFAQ maintainer on 18 September 2026 and the response was remarkable. The issue was confirmed about 40 minutes after my report, and a fix was committed within roughly two hours, landing in the main branch the same evening and in the nightly build the next day as a silent fix. The maintainer then published the advisory as SA-2026-10-03 with a High rating on 3 October 2026. Nothing was published before that advisory.

Acknowledgements

Special thanks to the phpMyFAQ maintainer. The report was confirmed in about 40 minutes and a working fix was committed roughly two hours after my email, on a Friday evening. That level of responsiveness, on personal time at the end of the week, is rare and deserves real credit. It is exactly what makes coordinated disclosure work, and it is a model of how to handle a security report.

Timeline

timeline
2026-09-18: reported to the phpMyFAQ maintainer
2026-09-18: confirmed about 40 minutes later
2026-09-18: fix committed about two hours after the report, landed in the main branch
2026-09-19: fix shipped in the nightly build as a silent fix
2026-10-03: maintainer published advisory SA-2026-10-03 (High)
2026-10-04: this write-up published

References