RS-22478: Sanitize as_html while keeping styles tag - #53
Conversation
|
|
||
| const STYLE_BLOCK = /<style\b[^>]*>([\s\S]*?)<\/style>/gi | ||
|
|
||
| function stripDangerousCss (css) { |
There was a problem hiding this comment.
The hand-rolled CSS scrub is weaker than DOMPurify's own CSS sanitizer and is bypassable via CSS character escapes — e.g. @\69 mport parses as @import in browsers but won't match /@import\b/. The comment correctly notes this is best-effort and that modern browsers no longer execute CSS-borne script, so severity is low. Worth considering the alternative: keep style in DOMPurify's ALLOWED_TAGS and let its built-in CSS parser handle @import/url()/expression() properly instead of pulling styles out. If that path was rejected because DOMPurify dropped the styles in the earlier attempt, a sentence in the PR explaining why would help.
There was a problem hiding this comment.
Hardened stripDangerousCss to decode CSS backslash escapes (hex + single-char) before the scrub, so @\69 mport and similar forms canonicalise to @import and get stripped. Added regression tests asserting the external URL is removed for both the plain and the CSS-escaped @import.
On letting DOMPurify's own CSS sanitizer handle this instead of pulling <style> out: this redo extracts styles because the first attempt (#51) dropped the table/widget CSS that flipFormat emits. I haven't been able to confirm DOMPurify's in-browser CSS sanitizer preserves that CSS (under jsdom it drops <style> entirely, though that's not conclusive for a real browser), so I'd rather not flip the approach in this PR. Keeping the extraction here and will file a follow-up to evaluate switching to DOMPurify's CSS sanitizer. Added a sentence to the PR description explaining why styles are separated.
There was a problem hiding this comment.
Followed up on the "let DOMPurify handle the CSS" alternative — tested DOMPurify 3.4.10 in real Chrome (puppeteer), not just jsdom, against the actual flipFormat cmd.css. It turns out DOMPurify has no CSS-content sanitizer for <style> elements:
- With the default config (and even
ADD_TAGS:["style"]) it drops the whole<style>block — confirmed in real Chrome, not a jsdom quirk. This is why the first attempt (RS-22478: fix stored XSS in metro HTML labels #51) lost the widget styling. <style>survives only withFORCE_BODY:true/WHOLE_DOCUMENT:true(an HTML-parser placement thing;styleis already in the default allow-list).- But when it is kept, the CSS passes through completely unsanitized —
@import, the escaped@\69 mport,expression(),-moz-binding,behavior,url(), evenurl("javascript:...")all survive verbatim.
So DOMPurify only ever drops the block or keeps it raw — there is no in-between "sanitize the CSS" path for <style> (it sanitises inline style= attributes only). That makes the extract-scrub-reinsert approach necessary, and the hand-rolled scrub is the only thing sanitising the <style> CSS (now also robust to the escaped forms). Residual is plain url() resource-loads, which neither approach strips — low severity (no script execution). Updated the PR description to capture this.
- Guard nullish input so it renders as '' rather than the literal "null"/ "undefined" that String(text) produced (review comment). - Add a DOMPurify afterSanitizeAttributes hook that sets rel="noopener noreferrer" on anchors carrying a target, preventing reverse tabnabbing from target="_blank". - Decode CSS backslash escapes before the CSS scrub so escaped forms such as @\69 mport canonicalise to @import and are stripped, closing the regex-bypass the reviewer flagged. Adds sanitizeHtml.jest.test.js covering script/handler stripping, style retention, iframe/video embeds, the nullish guard, the noopener rel, and both the plain and CSS-escaped @import cases. Rebuilt the htmlwidgets bundle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DOMPurify does not sanitize <style> content (it drops the block or keeps it verbatim), so stripDangerousCss is the sole defence for that surface. Lock each vector down: expression() (plus its CSS-escaped form), -moz-binding, behavior, and javascript: URIs are stripped; legitimate multi-rule CSS, url() background images, and multiple ordered <style> blocks are preserved. Each vector test also asserts a co-located benign rule survives, so an over-aggressive scrub would fail too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
JustinCCYap
left a comment
There was a problem hiding this comment.
Thanks for the thorough updates — all six points from the previous round are addressed (tests, escape-aware scrub, iframe rationale, rel=noopener hook, nullish guard, style-order test). However, the escape-decoding added to close the @import bypass has introduced a higher-severity hole. Two changes needed before merge — see inline.
| } | ||
| const styles = [] | ||
| const withoutStyles = String(text).replace(STYLE_BLOCK, function (match, css) { | ||
| styles.push('<style>' + stripDangerousCss(css) + '</style>') |
There was a problem hiding this comment.
🔴 Blocker: this reintroduces <style> breakout XSS. The decoded CSS from stripDangerousCss is re-inserted verbatim inside <style>…</style> here, and this styles string is prepended outside the DOMPurify call (line 61), so it is never sanitized by DOMPurify. <style> is an HTML rawtext element, so a literal </style> in its content terminates it — and now that decodeCssEscapes runs first, a CSS-escaped </style> decodes into a real one.
PoC (verified by running the actual decodeCssEscapes/stripDangerousCss/STYLE_BLOCK logic from this diff):
input: <style>x\3C/style\3E\3Cimg src=x onerror=alert(1)\3E</style>
output: <style>x</style><img src=x onerror=alert(1)></style>
The escaped \3C/style\3E slips past the non-greedy STYLE_BLOCK regex (not a literal </style> yet), then decoding turns it into a real </style> plus a live <img onerror> that executes. This is the exact XSS class the PR exists to prevent — a regression created by the escape-decoding fix.
Fix: never re-insert decoded text into the rawtext <style> context unescaped. Cleanest is to re-escape </> back to CSS escapes after scrubbing (renders identically in CSS, cannot break out):
function stripDangerousCss (css) {
return decodeCssEscapes(String(css))
.replace(/@import\b[^;}]*;?/gi, '')
.replace(/expression\s*\(/gi, '')
.replace(/-moz-binding\b[^;}]*;?/gi, '')
.replace(/\bbehaviou?r\s*:[^;}]*;?/gi, '')
.replace(/javascript\s*:/gi, '')
.replace(/</g, '\\3C ') // neutralise </style> breakout
.replace(/>/g, '\\3E ')
}Please add a regression test asserting \3C/style\3E… does not yield a literal </style><…> in the output.
There was a problem hiding this comment.
Confirmed and fixed — good catch, this was a real regression from the escape-decoding change. Reproduced your PoC: <style>x\3C/style\3E\3Cimg src=x onerror=alert(1)\3E</style> produced a live <img onerror> outside the <style>.
Fix: after the scrub, re-escape < back to the CSS escape \3C (renders identically) so the re-inserted CSS can't break out of the rawtext <style>. I escape only <, not > — > can't terminate a <style> element, and escaping it would break legitimate > child combinators in as_html CSS.
Verified the fix in real Chrome (puppeteer): the sanitized output is <style>x\3C /style>\3C img ...></style>, and inserting it via .html() yields 0 <img>, 0 <script>, 1 intact <style>, and the onerror never fires. Added a regression test that parses the sanitized output and asserts no <img>/<script> element is produced.
| // renders identically.) | ||
| function decodeCssEscapes (css) { | ||
| return css | ||
| .replace(/\\([0-9a-fA-F]{1,6})[ \t\n\f\r]?/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16))) |
There was a problem hiding this comment.
🟠 String.fromCodePoint throws on out-of-range codepoints, which propagates out of sanitizeHtml and breaks the entire Box/label render (a content-controlled DoS). Verified:
input: <style>.a{content:"\110000"}</style>
result: RangeError - Invalid code point 1114112
Any escape above U+10FFFF (or in the surrogate range) triggers it. Per the CSS spec, such escapes should map to U+FFFD, not error:
.replace(/\\([0-9a-fA-F]{1,6})[ \t\n\f\r]?/g, (_, hex) => {
const cp = parseInt(hex, 16)
return (cp === 0 || cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF))
? '\uFFFD' : String.fromCodePoint(cp)
})Minor, related: decoding escapes globally and then regex-stripping can alter legitimate CSS where an escape was semantically meaningful (e.g. inside a content: "…" string, or an escape that prevents token concatenation), so the "renders identically" comment isn't fully accurate. Low impact for this use case — just flagging.
There was a problem hiding this comment.
Fixed. decodeCssEscapes now guards the codepoint and maps null / out-of-range (> U+10FFFF) / surrogate escapes to U+FFFD per the CSS spec, instead of letting String.fromCodePoint throw:
.replace(/\\([0-9a-fA-F]{1,6})[ \t\n\f\r]?/g, (_, hex) => {
const cp = parseInt(hex, 16)
return (cp === 0 || cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) ? '�' : String.fromCodePoint(cp)
})Added regression tests asserting both \110000 and a surrogate \d800 no longer throw.
On the related note: agreed — I've corrected the code comment so it no longer claims decoding "renders identically"; it now notes that global decoding can alter otherwise-meaningful escapes (in content: strings, or token-separating escapes), an accepted trade-off on this best-effort, lower-severity surface to defeat the escape-based bypass.
Two issues surfaced in review of the CSS escape-decoding change: - Blocker (XSS): the scrubbed CSS is re-inserted into a rawtext <style> outside DOMPurify. Once decodeCssEscapes ran, a CSS-escaped </style> (\3C/style\3E) decoded into a real </style> that terminated the block, and a following \3Cimg ...\3E became a live, unsanitized <img onerror>. Re-escape '<' to the CSS escape '\3C ' after scrubbing so the content cannot break out of <style> (renders identically). '>' is left intact: it can't terminate <style> and escaping it would break '>' child combinators. - DoS: String.fromCodePoint threw a RangeError on out-of-range/surrogate escapes (e.g. \110000), propagating out and breaking the whole render. Map null/out-of-range/surrogate escapes to U+FFFD per the CSS spec. Also corrected the decode comment to stop claiming "renders identically" (global decoding can alter meaningful escapes — an accepted trade-off). Regression tests: the \3C/style\3E breakout produces no <img>/<script> element (verified in real Chrome too), out-of-range escapes don't throw, and '>' child combinators are preserved. Rebuilt the bundle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
JustinCCYap
left a comment
There was a problem hiding this comment.
LGTM, same comment about testing before merging as the rhtmlCombinedScatter PR.
Second attempt to sanitise
as_htmllabel content with DOMPurify. This time we separate out the<style>tags and sanitize them separately so they are retained.Why styles are extracted and scrubbed separately
DOMPurify has no CSS-content sanitizer for
<style>elements — verified in real Chrome (DOMPurify 3.4.10): with the default config it drops the whole<style>block (this is what broke #51), and the only way to keep it (FORCE_BODY/WHOLE_DOCUMENT) passes the CSS through completely unsanitized (@import,expression(),-moz-binding,behavior,url()all survive). So we pull<style>out first, scrub their CSS ourselves — best-effort, defence-in-depth for the lower-severity CSS surface (@import/external loads/legacyexpression()/behavior/-moz-binding, now also robust to CSS backslash escapes such as@\69 mport) — run everything else through DOMPurify (which removes scripts, event-handler attributes,javascript:etc.), then re-insert the scrubbed styles. Residual: plainurl()resource-loads are not stripped (low severity, no script execution).Why
<iframe>is allowedMetro supports embedded video — YouTube (
<iframe src=".../embed/...">) and<video controls>(see the VIS-1010/RS-12497 test). DOMPurify still blockssrc="javascript:".sandboxis intentionally not applied because YouTube embeds requireallow-scripts+allow-same-origin, which together negate most of its protection.Anchor targets
Anchors with a
targetgetrel="noopener noreferrer"via anafterSanitizeAttributeshook to prevent reverse tabnabbing.