fix: Strip Every Tag From the Text of Kept Literal-Content Elements - #465
Merged
Merged
Conversation
The filter that the CVE-2025-66021 fix put on the text of a kept style or script element re-emitted the start tag of any element the policy allowed, attributes and all, without running an attribute policy over it, and kept an end tag whenever its element was allowed. A browser with scripting on reads noscript as raw text up to the first </noscript> and parses what follows as markup, so under a policy allowing noscript, style with text and img, <noscript><style></noscript><img src=x onerror=alert(1)></style></noscript> came out unchanged and ran the handler. noframes and noembed break out the same way with scripting off, and iframe and comment content is literal to the renderer too but never reached the filter, which looked only for style and script; a comment element's content is markup to every current browser. The filter now removes every tag, end tags first of all, from the text of any element whose content the renderer emits unescaped, judged by the escaping mode of the name the renderer will use. A start tag still goes with its content through a matching end tag in the same chunk, so a script inside a style block goes entirely, but one with no matching end tag now goes alone rather than taking the rest of the chunk with it, and a < that opens no tag stays, except where a following chunk could complete it into an end tag. Chunks arrive with boundaries anywhere, so each is made safe on its own; a test delivers the text one character at a time. Tests #3, #4 and #5 from the original fix pinned the re-emitted div and the kept </noscript> and </p>; their expectations now show those gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VYF1WjZmwbGNrph7RDw2BS
A reviewer traced a gap in the new "keep a < that opens no tag" rule: in a comment element, whose content every current browser parses as markup, a start tag with no > of its own was completed by the > of the sanitizer's own </comment>, so <comment>x<img src=x onerror=alert(1)//</comment> ran the handler. The gap was the sanitizer's, not the rule's: it read the IE-only comment element as raw text when no browser does, and emitted that text unescaped. The element is now unknown, like it is to browsers, so its content is lexed as markup, vetted by the policy and escaped by the renderer. The literal-text filter no longer needs to know about it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VYF1WjZmwbGNrph7RDw2BS
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VYF1WjZmwbGNrph7RDw2BS
This was referenced Sep 10, 2026
Closed
Closed
daham-13
pushed a commit
to daham-13/java-html-sanitizer
that referenced
this pull request
Sep 16, 2026
…renderer The review of OWASP#465 found two working bypasses of the filter on kept style, script and iframe text, one of them present in the original CVE-2025-66021 fix as well. A "<" that opened no tag carried everything up to the next ">" through as text, and that ">" could belong to an end tag inside the span: for "< </noscript>" or "<</noscript>" the span was "< </noscript>", not a tag, so the </noscript> that a bare one lost survived, and with it the breakout into markup. The scan now resumes right after such a "<". Since a "<" left just before a dropped tag would meet whatever follows the tag, "<<b>/noscript>" yielding "</noscript>", it goes with the tag. A "<" left dangling at the end of a chunk could be completed by the next chunk, which a server-side script tag inside the element or a preprocessor can produce, or by the sanitizer's own end tag once a browser is reading markup. Such a "<" now goes unless HTML whitespace follows it, which starts no tag in any browser state; a "<" with a ">" after it in the chunk opened no tag and stays, so comments in script text survive. HtmlStreamRenderer's check on buffered literal content now also refuses an end tag of noscript, noframes or noembed, the elements the sanitizer treats as containers while a browser reads them as raw text, so a policy that never runs the filter, such as one built by hand on PolicyFactory.apply, is covered too. The linearity test gets the timeout its sibling has, the change log's claim about noframes and noembed is corrected, and a stale comment about StylingPolicy goes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VYF1WjZmwbGNrph7RDw2BS
daham-13
pushed a commit
to daham-13/java-html-sanitizer
that referenced
this pull request
Sep 16, 2026
The filter on the text of a kept style, script or iframe element, reworked in OWASP#465, is rewritten as one pass that keeps no record per tag. - A tag now needs a well-formed name, so ordinary script and style text survives: "if (a < b) { x(); } if (c > d) { y(); }" keeps the text between the brackets (OWASP#470). A name that only a character the renderer elides hides, as in "</noscript" NUL ">", is still found and removed. - Pairing a start tag with its end tag reads a bounded note of the starts not yet matched, compares names where they lie, and records removed ranges only for a listener, so the filter's memory is its output rather than a multiple of it: input that needed 256 MB sanitizes in under 96 MB (OWASP#473). - A removal no longer splices the text on either side of it into a comment delimiter or into a tag, nor drops a pair that would take a "-->" with it, each of which cost the whole content of the element (OWASP#475), and the sweep that keeps a removal from leaving a tag behind also closes "<style></<b>noscript>" emitting "</noscript>" to a receiver. - One predicate decides where text is emitted as written, so the filter runs exactly where nothing escapes a tag for it: text in foreign content is escaped by the renderer and kept whole, and the text of xmp, listing and plaintext is filtered for a receiver that does not rename them (OWASP#474). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4kNbd4REPF5ozRjnCu6tW
|
🎉 This issue has been resolved in |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Found while working on #463 and reported to the maintainer, who asked for a public PR.
The filter that the CVE-2025-66021 fix (d6e0463) put on the text of a kept
styleorscriptelement re-emitted the start tag of any element the policy allowed, attributes and all, with no attribute policy run over it, and kept an end tag whenever its element was allowed. A browser with scripting on readsnoscriptas raw text up to the first</noscript>and parses what follows as markup, so under a policy allowingnoscript,stylewith text andimg:came out unchanged and runs the handler. The normal path strips
onerror.noframesandnoembedbreak out the same way regardless of scripting, andiframecontent is literal to the renderer too but never reached the filter, which looked only forstyleandscript.The IE-only
commentelement was a separate hole: the sanitizer read its content as raw text and emitted it unescaped, while every current browser parses that content as markup, so a tag inside it reached the browser unvetted with no breakout needed. A reviewer also showed that, with the filter in place, a start tag with no>of its own insidecommenttext was completed by the>of the sanitizer's own</comment>. The element is no longer classified as literal content at all: its content is lexed as markup, vetted by the policy and escaped by the renderer, like any unknown element's.xmp,listingandplaintextbecomepreand are escaped, so they are not affected.<script>alert(1)</script>inside a style block goes entirely, but a start tag with no matching end tag now goes alone instead of taking the rest of the chunk with it, and a<that opens no tag stays, except where a following chunk could complete it into an end tag. That also fixes the pre-existing loss ofif (a < b)in kept script text.divand the kept</noscript>and</p>; their expectations now show those gone, with the reason in each javadoc.Test plan
HtmlSanitizerTest: theimgandbhandler payloads throughnoscript, the same throughnoframesandnoembedwith every end tag spelling a browser accepts,iframecontent, thecommentelement with a complete and with an unterminated start tag inside it, the one-character-chunk case, a chunk of 200,000 unmatched tags staying linear, and the fidelity cases for a bare<, an unmatched start tag and a matched one../mvnw clean verifypasses on JDK 11, 17, 21 and 25.🤖 Generated with Claude Code
https://claude.ai/code/session_01VYF1WjZmwbGNrph7RDw2BS