Skip to content

Rework the literal-text tag filter for fidelity and footprint - #478

Merged
jmanico merged 2 commits into
mainfrom
issue-470-473-474-475-literal-text-filter
Sep 11, 2026
Merged

Rework the literal-text tag filter for fidelity and footprint#478
jmanico merged 2 commits into
mainfrom
issue-470-473-474-475-literal-text-filter

Conversation

@jmanico

@jmanico jmanico commented Sep 11, 2026

Copy link
Copy Markdown
Member

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 <, optional
whitespace, a letter and everything to the next > for a tag, so ordinary
script 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:

input before now
if (a < b) { x(); } if (c > d) { y(); } if (a d) { y(); } unchanged
for(i=0;i<n;i++){a[i]=b>c;} for(i=0;ic;} unchanged
var s = 'a<b'; var t = 'c>d'; var s = 'ad'; unchanged

Fixture #6 still strips < script>, and a name the renderer's elision would
otherwise assemble (</noscript NUL >, DEL, a C1 control) is still removed —
Encoding gains isPossiblyElidedCodeunit for that, since a policy has to
judge 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)", onmouseover a function, fires), so the question is
whether 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 select is dropped by the balancer (CVE-2021-42575), and in foreign
content the renderer escapes. Chrome also confirms </b)> does not end a
raw-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, no ArrayList,
no ArrayDeque, no boxed Integer, and tagNameOf's String.split("\\s") is
gone. 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): OutOfMemoryError at -Xmx160m
    before, sanitizes at -Xmx96m now.
  • Allocation for 200k tags: 836 bytes/tag before, 111 now — of which 104 is what
    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 could
take a --> with it; either left the element's comments unbalanced, which costs
its whole content:

input before now
<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> content kept

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 straddle
chunks (<style>a{}-<%%><b>->b{}</style> was empty too). A pair that would take
a delimiter with it is dropped tag by tag instead.

#474 — one predicate for where text is emitted as written. HtmlStreamRenderer
now 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 the
    renderer as RCDATA, instead of having them stripped for nothing.
  • A receiver handed to PolicyFactory.apply does not rename xmp, listing and
    plaintext to pre, 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.OutputChannel becomes one of the library's own decorators so
the 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 main today 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)>x inside the style body: the input's tag was
    <b<svg> and what followed it was text, so removing the tag turned that text
    into a live handler. A sweep now takes the < of anything a removal, or the
    element's own end tag, would finish into a tag, while <3 and < b stay.
  • <style></<b>noscript></style> handed a receiver </noscript> verbatim — a
    real breakout for a custom receiver, caught only by the renderer's backstop on
    the sanitize path. It now emits /noscript>.

Test plan

  • 18 new or reworked tests in HtmlSanitizerTest; 8 of them fail against
    main's library code, one per issue and then some, and the listener test fails
    without the reporter change. 551 tests pass in all.
  • Three linearity tests guard the scan's worst cases, each of which I measured
    quadratic first: 1M < that open no tag with one > at the end (101 s → 92
    ms), a 1.5M-long run of unfinished tags ended by a removable one (12 s → 80
    ms), and the existing 200k unmatched tags.
  • Local fuzzing beyond the suite: 2.4M hostile literal-text cases, delivered
    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. main leaves ~3,900 tag shapes
    per 200k cases, including the </noscript> above; this leaves none. Output
    idempotence 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 verify passes on JDK 11, 17, 21 and 25, fuzzers, AntiSamy suite
    and JPMS consumer check included.

Fidelity left on the table

if (a<b || c>d) f(); still loses b || c: a name followed by whitespace is a
tag wherever a browser reads markup, exactly like the < script> that fixture #6
pins, and the filter cannot tell them apart. A test records that.

🤖 Generated with Claude Code

https://claude.ai/code/session_01L4kNbd4REPF5ozRjnCu6tW

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
@jmanico

jmanico commented Sep 11, 2026

Copy link
Copy Markdown
Member Author

Claude Code review — 1 finding(s)

nit — owasp-java-html-sanitizer/src/main/java/org/owasp/html/HtmlStreamRenderer.java:66
Stale Javadoc {@ link #foreignContentRootElementNames} references a field renamed in this diff to FOREIGN_CONTENT_ROOT_ELEMENT_NAMES


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
@jmanico
jmanico merged commit ca42627 into main Sep 11, 2026
7 checks passed
@jmanico
jmanico deleted the issue-470-473-474-475-literal-text-filter branch September 11, 2026 04:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment