Skip to content

fix: Strip Every Tag From the Text of Kept Literal-Content Elements - #465

Merged
jmanico merged 3 commits into
mainfrom
cdata-text-end-tags
Sep 10, 2026
Merged

jmanico merged 3 commits into
mainfrom
cdata-text-end-tags

Conversation

@jmanico

@jmanico jmanico commented Sep 10, 2026 •

Copy link
Copy Markdown
Member

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 style or script element 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 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 runs the handler. The normal path strips onerror. noframes and noembed break out the same way regardless of scripting, and iframe content is literal to the renderer too but never reached the filter, which looked only for style and script.

The IE-only comment element 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 inside comment text 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.

  • The filter now removes every tag from the text of any element whose content the renderer emits unescaped, end tags first of all, judged by the escaping mode of the name the renderer will use. xmp, listing and plaintext become pre and are escaped, so they are not affected.
  • A start tag still goes with its content through a matching end tag in the same chunk, so <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 of if (a < b) in kept script text.
  • Text arrives in chunks with boundaries anywhere, so each chunk is made safe on its own; a test delivers the content one character at a time through a preprocessor.
  • Tests Deeply nested elements crash FF 8, Chrome 11 #3, NUL byte in input will cause output to be blank. #4 and HtmlPolicyBuilder.allowUrlProtocols() doesn't work #5 from the original fix pinned the re-emitted div and the kept </noscript> and </p>; their expectations now show those gone, with the reason in each javadoc.

Test plan

  • New tests in HtmlSanitizerTest: the img and b handler payloads through noscript, the same through noframes and noembed with every end tag spelling a browser accepts, iframe content, the comment element 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.
  • All existing tests pass, with the three expectation changes above.
  • ./mvnw clean verify passes on JDK 11, 17, 21 and 25.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VYF1WjZmwbGNrph7RDw2BS

jmanico and others added 2 commits September 10, 2026 10:50
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
@jmanico
jmanico merged commit 8ada8fe into main Sep 10, 2026
7 checks passed
@jmanico
jmanico deleted the cdata-text-end-tags branch September 10, 2026 22:49
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
@github-actions

Copy link
Copy Markdown

🎉 This issue has been resolved in release-20260921.1 (Release Notes)

@github-actions github-actions Bot added the released Issue has been released label Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

released Issue has been released

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant