Rework the literal-text tag filter for fidelity and footprint - #478
Merged
Conversation
The filter on the text of a kept style, script or iframe element, reworked in #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 (#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 (#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 (#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 (#474). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4kNbd4REPF5ozRjnCu6tW
Member
Author
|
Claude Code review — 1 finding(s) nit — owasp-java-html-sanitizer/src/main/java/org/owasp/html/HtmlStreamRenderer.java:66 Generated by Claude Code |
The field was renamed to FOREIGN_CONTENT_ROOT_ELEMENT_NAMES but the Javadoc on foreignContentDepth still linked to the old name. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cknzjj79Yi15jqtaBEa9Lx
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.
Closes #470, closes #473, closes #474, closes #475.
Summary
The four follow-ups from the review of #465 all live in the same ~120 lines, so
the filter on the text of a kept literal-content element is rewritten once, as a
single pass that keeps no record per tag. Built on #477, which had just landed.
#470 — a tag needs a well-formed name. The filter took
<, optionalwhitespace, a letter and everything to the next
>for a tag, so ordinaryscript and style text lost the text between its brackets. A name is now an ASCII
letter and then letters, digits,
-,_,:or., ending at whitespace,/or
>; running into anything else the output keeps makes it no name:if (a < b) { x(); } if (c > d) { y(); }if (a d) { y(); }for(i=0;i<n;i++){a[i]=b>c;}for(i=0;ic;}var s = 'a<b'; var t = 'c>d';var s = 'ad';Fixture #6 still strips
< script>, and a name the renderer's elision wouldotherwise assemble (
</noscriptNUL>, DEL, a C1 control) is still removed —EncodinggainsisPossiblyElidedCodeunitfor that, since a policy has tojudge the text that will be emitted.
I checked the relaxation against Chrome 152 rather than the spec alone: a weird
name is a live element with a live handler where a browser parses markup
(
<b)→localName "b)",onmouseovera function, fires), so the question iswhether such text can reach a markup context. It cannot: an end tag that would
break a raw-text element open has a well-formed name and still goes, the
renderer refuses content holding one anyway (#472), text in a literal element
under a
selectis dropped by the balancer (CVE-2021-42575), and in foreigncontent the renderer escapes. Chrome also confirms
</b)>does not end araw-text
noscript, while</noscript/>,</noscript >and</NOSCRIPT>do— all of which the filter removes.
#473 — the filter's memory is its output. No
int[]per tag, noArrayList,no
ArrayDeque, no boxedInteger, andtagNameOf'sString.split("\\s")isgone. Pairing reads a bounded note of the start tags not yet matched and
compares names where they lie; removed ranges are recorded only while a listener
is attached. Fixtures #2 and #3 pin start-tag-plus-content removal, so the
pairing stays — bounded, because it buys fidelity rather than safety.
<style>+ 3M ×<b>x+</style>(12 MB):OutOfMemoryErrorat-Xmx160mbefore, sanitizes at
-Xmx96mnow.the lexer spends on the same text either way, so the filter's own share went
from ~730 bytes/tag to ~7. The new test measures that difference.
#475 — a removal must not splice something new. Joining the text on either
side of a removed tag could make a
<!--or-->, and a dropped pair couldtake a
-->with it; either left the element's comments unbalanced, which costsits whole content:
<style>a{}-<b>->b{}</style><style></style><style>a{}- ->b{}</style><style>a{}--<b>>b{}</style><style></style><style>a{}-- >b{}</style><script><!-- if (a > 0) { <b> } --> </b> f();</script><script></script>A space goes in only where the join would make a delimiter, so no existing
expectation moves; the element's text is remembered across chunk boundaries,
because the lexer splits literal text at
<%...%>and the join can straddlechunks (
<style>a{}-<%%><b>->b{}</style>was empty too). A pair that would takea delimiter with it is dropped tag by tag instead.
#474 — one predicate for where text is emitted as written.
HtmlStreamRenderernow has the only copy of that decision and the policy asks it, with the
foreign-content depth the policy tracks alongside its other per-element gates:
<svg><style>a{}<b>x</b>c{}</style></svg>keeps its tags again, escaped by therenderer as RCDATA, instead of having them stripped for nothing.
PolicyFactory.applydoes not renamexmp,listingandplaintexttopre, so it used to receive their text with a</noscript>intact, and the renderer-side check does not run for it. With any receiver but
the library's own renderer, every element whose content the lexer read as raw
text is filtered.
HtmlChangeReporter.OutputChannelbecomes one of the library's own decorators sothe renderer stays visible behind it; otherwise attaching a listener would change
what the filter strips, which a new test pins.
Two more holes the fuzzing turned up
Both are on
maintoday and are fixed here, with the splice tests beside the#475 ones:
<style><b<svg> onmouseover=alert(1)>x</style>emitted<b onmouseover=alert(1)>xinside the style body: the input's tag was<b<svg>and what followed it was text, so removing the tag turned that textinto a live handler. A sweep now takes the
<of anything a removal, or theelement's own end tag, would finish into a tag, while
<3and< bstay.<style></<b>noscript></style>handed a receiver</noscript>verbatim — areal breakout for a custom receiver, caught only by the renderer's backstop on
the
sanitizepath. It now emits/noscript>.Test plan
HtmlSanitizerTest; 8 of them fail againstmain's library code, one per issue and then some, and the listener test failswithout the reporter change. 551 tests pass in all.
quadratic first: 1M
<that open no tag with one>at the end (101 s → 92ms), a 1.5M-long run of unfinished tags ended by a removable one (12 s → 80
ms), and the existing 200k unmatched tags.
whole and one character at a time, with an oracle for an end tag a browser
acts on and for a start tag a browser acts on.
mainleaves ~3,900 tag shapesper 200k cases, including the
</noscript>above; this leaves none. Outputidempotence also improves (14.2% → 10.1% of cases differ on a second pass; the
remainder is fix: Strip Every Tag From the Text of Kept Literal-Content Elements #465's dangling-
<rule, unchanged here)../mvnw clean verifypasses on JDK 11, 17, 21 and 25, fuzzers, AntiSamy suiteand JPMS consumer check included.
Fidelity left on the table
if (a<b || c>d) f();still losesb || c: a name followed by whitespace is atag wherever a browser reads markup, exactly like the
< script>that fixture #6pins, and the filter cannot tell them apart. A test records that.
🤖 Generated with Claude Code
https://claude.ai/code/session_01L4kNbd4REPF5ozRjnCu6tW