Skip to content

🚨 [security] Update sanitize-html 2.12.1 → 2.17.6 (minor) - #327

Open
depfu[bot] wants to merge 1 commit into
mainfrom
depfu/update/yarn/sanitize-html-2.17.6
Open

🚨 [security] Update sanitize-html 2.12.1 → 2.17.6 (minor)#327
depfu[bot] wants to merge 1 commit into
mainfrom
depfu/update/yarn/sanitize-html-2.17.6

Conversation

@depfu

@depfu depfu Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🚨 Your current dependencies have known security vulnerabilities 🚨

This dependency update fixes known security vulnerabilities. Please see the details below and assess their impact carefully. We recommend to merge and deploy this as soon as possible!


Here is everything you need to know about this update. Please take a good look at what changed and the test results before merging this pull request.

What changed?

✳️ sanitize-html (2.12.1 → 2.17.6) · Repo · Changelog

Security Advisories 🚨

🚨 sanitize-html has incomplete URI scheme validation in that allows javascript: URIs through action, formaction, data, poster, and background attributes

Summary

sanitize-html uses allowedSchemesAppliedToAttributes (default: ['href', 'src', 'cite']) to gate the naughtyHref() function that blocks dangerous URI schemes like javascript: and vbscript:. The HTML specification defines 10+ attributes that accept URIs (action, formaction, data, poster, background, ping, xlink:href, dynsrc, lowsrc), but none of these are included in the default gate list. When a developer allows any of these attributes in their configuration, javascript: URIs pass through completely unmodified, enabling XSS.

The library has zero awareness of these URI-bearing attributes — none appear anywhere in the 854-line source file (verified by grep). No warning mechanism exists, and the README provides no security guidance about expanding allowedSchemesAppliedToAttributes when allowing form or media attributes.

Severity

Exploitation requires non-default configuration: the developer must explicitly allow a non-default tag (e.g., form) AND a non-default attribute (e.g., action). Default configuration is NOT vulnerable. However, this is a common configuration pattern for CMS platforms, form builders, and rich content editors.

Affected Versions

All versions of sanitize-html from v1.18.0 (which introduced allowedSchemesAppliedToAttributes) through at least v2.17.2. The default list has been ['href', 'src', 'cite'] since introduction and has never been expanded.

Root Cause

File: index.js:329 (sanitize-html 2.10.0, confirmed same in 2.17.x)

// Line 329 — The gate that controls scheme validation
if (options.allowedSchemesAppliedToAttributes.indexOf(a) >= 0) {
    if (naughtyHref(name, value)) {
        delete frame.attribs[a];
        return;
    }
}

Default list at line 829:

allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'],

The naughtyHref() function (lines 627-667) correctly blocks javascript:, vbscript:, and other dangerous schemes. However, it has exactly 2 call sites in the entire codebase (lines 330 and 395), both inside the indexOf gate. There is no ungated path.

When attribute name is action, formaction, data, poster, background, etc.:

  • indexOf('action') returns -1
  • The if block is skipped entirely
  • naughtyHref() is never called
  • javascript:alert(1) passes through unmodified

The escapeHtml() function at line 464 provides no defense — it only encodes & < > " characters, which are not present in javascript:alert(1).

Data Flow:

Attacker input: <form action="javascript:alert(document.cookie)">
1. htmlparser2 parses → tag='form', attribs={action:'javascript:alert(document.cookie)'}
2. index.js:298 → allowedAttributes check: 'action' in developer config → PASS
3. index.js:329 → ['href','src','cite'].indexOf('action') → -1 → SKIP naughtyHref()
4. index.js:464 → escapeHtml('javascript:alert(document.cookie)') → unchanged
5. OUTPUT: <form action="javascript:alert(document.cookie)">

Steps to Reproduce

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

// ===== VECTOR 1: form action (100% reliable, all modern browsers) =====
const v1 = sanitize(
'<form action="javascript:alert(document.cookie)"><button>Submit</button></form>',
{
allowedTags: ['form', 'button'],
allowedAttributes: { form: ['action'] }
}
);
console.log('V1 (action):', v1);
// OUTPUT: <form action="javascript:alert(document.cookie)"><button>Submit</button></form>
// XSS triggers when user submits the form

// ===== VECTOR 2: button formaction (100% reliable) =====
const v2 = sanitize(
'<button formaction="javascript:alert(1)">Click</button>',
{
allowedTags: ['button'],
allowedAttributes: { button: ['formaction'] }
}
);
console.log('V2 (formaction):', v2);
// OUTPUT: <button formaction="javascript:alert(1)">Click</button>

// ===== VECTOR 3: object data =====
const v3 = sanitize(
'<object data="javascript:alert(1)"></object>',
{
allowedTags: ['object'],
allowedAttributes: { object: ['data'] }
}
);
console.log('V3 (data):', v3);
// OUTPUT: <object data="javascript:alert(1)"></object>

// ===== CONTROL: href IS scheme-checked (expected behavior) =====
const ctrl = sanitize(
'<a href="javascript:alert(1)">click</a>',
{
allowedTags: ['a'],
allowedAttributes: { a: ['href'] }
}
);
console.log('Control (href):', ctrl);
// OUTPUT: <a>click</a> ← href correctly stripped by naughtyHref()

Observed behavior: javascript: preserved on action/formaction/data but correctly stripped on href.

Expected behavior: javascript: should be stripped on ALL URI-bearing attributes, or at minimum, the library should warn developers when they allow URI-bearing attributes not covered by scheme validation.

Impact

An attacker can achieve XSS in applications that use sanitize-html with non-default configurations allowing URI-bearing attributes:

  • <form action="javascript:..."> — XSS on form submission (all modern browsers)
  • <button formaction="javascript:..."> — per-button XSS override (all modern browsers)
  • <object data="javascript:..."> — object load XSS (Chrome, Firefox)
  • <video poster="javascript:..."> — limited browser support but spec-valid

Common vulnerable configurations:

  • CMS platforms allowing form elements for user-generated content
  • Form builder applications
  • Rich text editors with extended tag allowlists
  • Email template editors allowing media/embed tags

Mitigating factors:

  • Default configuration is NOT vulnerable
  • Requires double opt-in: non-default tag + non-default attribute
  • CSP form-action directive mitigates form-based vectors
  • Developers CAN manually add attributes to allowedSchemesAppliedToAttributes

Remediation

Option 1 (Recommended): Expand the default allowedSchemesAppliedToAttributes list:

// index.js line 829, change from:
allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'],

// to:
allowedSchemesAppliedToAttributes: [
'href', 'src', 'cite', 'action', 'formaction',
'data', 'poster', 'background', 'ping',
'xlink:href', 'dynsrc', 'lowsrc'
],

Option 2: Apply naughtyHref() to ALL attributes by default (invert the gate logic).

Option 3: Add a runtime warning when developers allow URI-bearing attributes not in allowedSchemesAppliedToAttributes (analogous to vulnerableTags warning for script/style at lines 124-129).

Reporter

Kevin Lee (Changseon Lee)
OPCIA Corp. / PeanutAI Inc.
Seoul, South Korea
GitHub: crattack

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

Summary

Under the default configuration, sanitize-html can turn attacker-controlled content inside a disallowed xmp element into live HTML or JavaScript. This is a sanitizer bypass in the default disallowedTagsMode: 'discard' path and can lead to stored XSS in applications that render sanitized output back to users.

Details

In sanitize-html@2.17.3, the default nonTextTags list includes only script, style, textarea, and option in index.js lines 138-142. That means disallowed xmp tags are not treated as "drop the entire contents" tags.

Later, in the ontext handler at index.js lines 569-577, the code special-cases textarea and xmp and appends their text content directly to the output without escaping:

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

Because htmlparser2 treats xmp as a raw-text element, markup inside xmp is parsed as text on input but becomes live markup again once it is appended unescaped to the sanitized output.

This creates a default sanitizer bypass. For example, a disallowed <xmp> wrapper can be used to smuggle <script> or event-handler payloads through sanitization.

The README also appears to contradict the implementation. In the "Discarding the entire contents of a disallowed tag" section, the documented exception list names only style, script, textarea, and option, and does not mention xmp.

PoC

Tested locally against sanitize-html@2.17.3 on Node.js v25.2.1.

  1. Install the package:
npm install sanitize-html
  1. Run the following script:
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>'));

  1. Observed output:
<script>alert(1)</script>
<img src=x onerror=alert(1)>
<svg><script>alert(1)</script></svg>
  1. Render any of the returned strings in a browser context that trusts sanitize-html output, for example:
const dirty = '<xmp><script>alert(1)</script></xmp>';
const clean = sanitizeHtml(dirty);

If clean is inserted into the DOM or stored and later rendered as trusted HTML, the attacker-controlled script executes.

Impact

This is a cross-site scripting vulnerability in the default sanitizer behavior. Any application that uses sanitize-html defaults and then renders the returned HTML as trusted output is impacted. A remote attacker who can submit HTML content can trigger execution of arbitrary JavaScript in another user's browser when that content is viewed.

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

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:

} 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:

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

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;alert(1)&lt;/script&gt;</option>
// Actual (vulnerable): <option><script>alert(1)</script></option>

Step 2: Element breakout with XSS event handler

const output2 = sanitize(
  '<option>&lt;/option&gt;&lt;img src=x onerror=alert(document.cookie)&gt;</option>',
  { allowedTags: ['option'] }
);
console.log(output2);
// Output: <option></option><img src=x onerror=alert(document.cookie)></option>
// The <img> tag escapes the option context and executes the onerror handler

Step 3: Textarea breakout (also vulnerable)

const output3 = sanitize(
  '<textarea>&lt;/textarea&gt;&lt;img src=x onerror=alert(1)&gt;</textarea>',
  { allowedTags: ['textarea'] }
);
console.log(output3);
// Output: <textarea></textarea><img src=x onerror=alert(1)></textarea>

Step 4: Full select/option context breakout

const output4 = sanitize(
  '<select><option>&lt;/option&gt;&lt;/select&gt;&lt;img src=x onerror=alert(1)&gt;</option></select>',
  { allowedTags: ['select', 'option'] }
);
console.log(output4);
// Output: <select><option></option></select><img src=x onerror=alert(1)></option></select>
// Breaks out of both option and select elements

All outputs verified against sanitize-html 2.17.2 with htmlparser2 10.x.

Impact

  • Complete allowedTags bypass: Any HTML tag can be injected through an allowed option or textarea element using entity encoding, defeating the core security guarantee of sanitize-html.
  • Stored XSS: Applications that sanitize user-submitted HTML and allow option or textarea tags (common in form builders, CMS platforms, rich text editors) are vulnerable to stored cross-site scripting.
  • Session hijacking: Attackers can inject event handlers (onerror, onload, etc.) to steal session cookies or authentication tokens.
  • Scope: Affects non-default configurations only — the default allowedTags does not include option or textarea. However, these tags are commonly allowed in applications that handle form-related HTML content.

Recommended Fix

Remove the vulnerable code block at lines 569-573 entirely. The escapeHtml branch (line 574) correctly handles these elements — htmlparser2 10.x decodes entities, and re-encoding with escapeHtml produces correct HTML output (entities are round-tripped, not double-encoded).

--- a/packages/sanitize-html/index.js
+++ b/packages/sanitize-html/index.js
@@ -566,11 +566,6 @@ function sanitizeHtml(html, options, _recursing) {
         // your concern, don't allow them. The same is essentially true for style tags
         // which have their own collection of XSS vectors.
         result += text;
-      } 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;
       } else if (!addedText) {
         const escaped = escapeHtml(text, false);
         if (options.textFilter) {

This fix restores the pre-49d0bb7 behavior where all non-script/style text content goes through escapeHtml(), ensuring decoded entities are properly re-encoded before output.

Release Notes

2.17.5 (from changelog)

Security

  • Added a number of new attributes to be protected against unsafe URLs, e.g. javascript: and similar. None of these are used in the default configuration of sanitize-html or apostrophe or likely to be used there, and some attributes, like an action for a form, are inherently unsafe to allow if XSS protection is your goal. Nevertheless it makes sense to block certain URL types where they are not appropriate. Some attributes are not supported at all by modern browsers but are included for completeness. Thanks to crattack for reporting the vulnerability.
  • Address a potential vulnerability when nonTextTags is configured in a nonstandard way. While it is never a good idea to remove known non-text tags from the standard list e.g. script, styles, etc., this change ensures that doing so does not result in nested tags being passed through without sanitization when they are not expressly allowed. (ApostropheCMS would never trigger this situation.) Thanks to Dipanshu singh for pointing out the issue and contributing the fix.

2.17.4 (from changelog)

Changes

  • sanitize-html and launder now share a single implementation of naughtyHref, based on that which previously existed in sanitize-html.

Security

  • Security vulnerability: the xmp tag could be used to pass forbidden markup through sanitize-html, even when xmp itself is not explicitly allowed All users of sanitize-html should update immediately. Thanks to Vincenzo Turturro for reporting the vulnerability.

2.17.3 (from changelog)

Security

  • Fix vulnerability introduced in version 2.17.2 that allowed XSS attacks if the developer chose to permit option tags. There was no vulnerability when not explicitly allowing option tags.

2.17.2 (from changelog)

Changes

  • Upgrade htmlparser2 from 8.x to 10.1.0. This improves security by correctly decoding zero-padded numeric character references (e.g., &#0000001) that previously bypassed javascript: URL detection. Also fixes double-encoding of entities inside raw text elements like textarea and option.

2.17.0 (from changelog)

  • Add preserveEscapedAttributes, allowing attributes on escaped disallowed tags to be retained. Thanks to Ben Elliot for this new option.

2.16.0 (from changelog)

  • Add onOpenTag and onCloseTag events to enable advanced filtering to hook into the parser. Thhanks to Rimvydas Naktinis.

2.15.0 (from changelog)

  • Allow keeping tag content when discarding with exclusive filter by returning "excludeTag". Thanks to rChaoz.

2.14.0 (from changelog)

  • Fix adding text with transformTags in cases where it originally had no text child elements. Thanks to f0x.

2.13.1 (from changelog)

  • Fix to allow regex in allowedClasses wildcard whitelist. Thanks to anak-dev.

2.13.0 (from changelog)

  • Documentation update regarding minimum supported TypeScript version.

  • Added disallowedTagsMode: completelyDiscard option to remove the content also in HTML. Thanks to Gauav Kumar for this addition.

Does any of this look wrong? Please let us know.


Depfu Status

Depfu will automatically keep this PR conflict-free, as long as you don't add any commits to this branch yourself. You can also trigger a rebase manually by commenting with @depfu rebase.

All Depfu comment commands
@​depfu rebase
Rebases against your default branch and redoes this update
@​depfu recreate
Recreates this PR, overwriting any edits that you've made to it
@​depfu merge
Merges this PR once your tests are passing and conflicts are resolved
@​depfu cancel merge
Cancels automatic merging of this PR
@​depfu close
Closes this PR and deletes the branch
@​depfu reopen
Restores the branch and reopens this PR (if it's closed)
@​depfu pause
Ignores all future updates for this dependency and closes this PR
@​depfu pause [minor|major]
Ignores all future minor/major updates for this dependency and closes this PR
@​depfu resume
Future versions of this dependency will create PRs again (leaves this PR as is)

@depfu
depfu Bot requested a review from maltejur as a code owner July 31, 2026 22:05
@depfu depfu Bot added the dependencies Pull requests that update a dependency file label Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants