GitHub Security Advisory: GHSA-9mrh-v2v3-xpfm
Package: sanitize-html (NPM)
Severity: MODERATE (CVSS 6.1)
CWE: CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
Vulnerable versions: >= 2.17.2, < 2.17.3
Patched in: 2.17.3

Summary: sanitize-html allowedTags Bypass via Entity-Decoded Text in nonTextTags Elements

Details:
## Summary

Commit 49d0bb7 introduced a regression in sanitize-html that bypasses `allowedTags` enforcement for text inside `nonTextTagsArray` elements (`textarea` and `option`). Entity-encoded HTML inside these elements passes through the sanitizer as decoded, unescaped HTML, allowing injection of arbitrary tags including XSS payloads. This affects any application using sanitize-html that includes `option` or `textarea` in its `allowedTags` configuration.

## Details

The vulnerable code is at `packages/sanitize-html/index.js:569-573`:

```javascript
} else if ((options.disallowedTagsMode === 'discard' || options.disallowedTagsMode === 'completelyDiscard') && (nonTextTagsArray.indexOf(tag) !== -1)) {
  // htmlparser2 does not decode entities inside raw text elements like
  // textarea and option. The text is already properly encoded, so pass
  // it through without additional escaping to avoid double-encoding.
  result += text;
}
```

The comment is factually incorrect. htmlparser2 10.x **does** decode HTML entities inside both `<textarea>` and `<option>` elements before passing text to the `ontext` callback. This can be verified:

```javascript
const htmlparser2 = require('htmlparser2');
const parser = new htmlparser2.Parser({
  ontext(text) { console.log(JSON.stringify(text)); }
});
parser.write('<option>&lt;script&gt;</option>');
// Outputs: "<", "script", ">"  — entities are decoded
```

Because the code assumes the text is "already properly encoded" and skips `escapeHtml()`, the decoded entities (`<`, `>`) are written directly to the output as literal HTML characters. This completely bypasses the `allowedTags` filter — any tag can be injected inside an allowed `option` or `textarea` element using entity encoding.

The execution flow:
1. Attacker submits: `<option>&lt;img src=x onerror=alert(1)&gt;</option>`
2. htmlparser2 parses and decodes entities → `ontext` receives `<img src=x onerror=alert(1)>`
3. Code at line 569 checks: tag is `option`, which is in `nonTextTagsArray` → true
4. Line 573: `result += text` — writes decoded text directly without escaping
5. Output: `<option><img src=x onerror=alert(1)></option>` — `<img>` tag injected despite not being in `allowedTags`

The `script` and `style` tags are handled separately at lines 563-568 (before the vulnerable block), so the effective vulnerability applies to `textarea` and `option`, plus any custom elements added to `nonTextTags` by the user.

Prior to commit 49d0bb7, text in these elements fell through to the `escapeHtml` branch (line 574-580), which correctly re-encoded the decoded entities.

## PoC

**Prerequisites:** Application using sanitize-html 2.17.2 with `option` or `textarea` in `allowedTags`.

**Step 1: Basic tag injection via option**
```javascript
const sanitize = require('sanitize-html');
const output = sanitize(
  '<option>&lt;script&gt;alert(1)&lt;/script&gt;</option>',
  { allowedTags: ['option'] }
);
console.log(output);
// Expected (safe): <option>&lt;script&gt;aler