From 9e055660e693d5b06786653c36e3b3c541d82700 Mon Sep 17 00:00:00 2001 From: Jim Manico Date: Thu, 10 Sep 2026 07:51:29 -1000 Subject: [PATCH 1/4] Fail closed when an HTML end tag's effect on foreign content is unknown The context tracker assumed that an integration point between the current node and the tracked bottom blocks every HTML end tag. That holds for end tags searched in the default, list-item and button scopes and for the "any other end tag" walk, but table scope is bounded only by html, table and template, so , and the rest of the table structure reach past foreignObject, desc, mi and annotation-xml to a table around the foreign content and close all of it. The tracker kept its foreign state, honored the self-closing flag on the next tag, and a dropped element such as no longer held the text after it. The HTML end-tag scan also popped through elements in the special category, where browsers ignore the tag, matched headings only by exact name although closes any open heading, popped the elements above a form on , which only removes the form, and cleared its stack on a stray end tag although the browser may still be in foreign content, so the next inside MathML was taken for an SVG root. The tracker now enters its existing unknown state, where only and close themselves, whenever an end tag reaches the HTML rules with an effect that depends on untracked ancestors, on the insertion mode, or on the list of active formatting elements: table-scope and template end tags, formatting end tags across a special element, stray end tags with nothing tracked in the way, and table, template, select and frameset start tags at an integration point. Within the tracked region the walk follows the spec: the special category for any other end tag, the scopes for the tags with their own rules, heading cross-matching, and form removal. Integration points and annotation-xml bound every scope. Verified against Chrome on the hand-built cases and on 2000 generated inputs, where the fix removes every divergence toward foreign state. Closes #461. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VYF1WjZmwbGNrph7RDw2BS --- change_log.md | 12 + .../java/org/owasp/html/HtmlSanitizer.java | 221 ++++++++++++++---- .../org/owasp/html/HtmlSanitizerTest.java | 157 ++++++++++++- 3 files changed, 338 insertions(+), 52 deletions(-) diff --git a/change_log.md b/change_log.md index 8a0b07e3..5322f3c0 100644 --- a/change_log.md +++ b/change_log.md @@ -2,6 +2,18 @@ Most recent at top. * Next release + * The context tracker behind self-closing SVG and MathML tags now fails + closed when an end tag reaches the HTML rules with an effect it cannot + derive from the elements it tracks: end tags processed with table + scope (``, `` and the rest of the table structure), + `` through ``, ``, a formatting end tag across a + special element, a stray end tag that may name an ancestor of the + foreign root, and table, `
` is still open inside it, as in browsers. Issue #461. * Self-closing SVG and MathML handling now follows the browser's current tree-construction context through HTML integration points, foreign content breakout tags, mismatched foreign end tags, and the end tags diff --git a/owasp-java-html-sanitizer/src/main/java/org/owasp/html/HtmlSanitizer.java b/owasp-java-html-sanitizer/src/main/java/org/owasp/html/HtmlSanitizer.java index 9335c4bb..f7da57ed 100644 --- a/owasp-java-html-sanitizer/src/main/java/org/owasp/html/HtmlSanitizer.java +++ b/owasp-java-html-sanitizer/src/main/java/org/owasp/html/HtmlSanitizer.java @@ -243,8 +243,11 @@ private static final class ForeignContentContext { private final List openElements = new ArrayList<>(); /** - * True once the bounded stack is exhausted. The legacy HTML behavior is - * the conservative fallback for ordinary tags from that point onward. + * True once the browser's context can no longer be derived from the + * tracked elements: the bounded stack was exhausted, or a tag's effect + * depended on untracked ancestors or on the insertion mode. The legacy + * HTML behavior is the conservative fallback for ordinary tags from that + * point onward. */ private boolean unknown; @@ -282,58 +285,103 @@ && breaksOutOfForeignContent(elementName, attrs)) { void processEndTag(String elementName) { if (unknown || openElements.isEmpty()) { return; } - OpenElement current = currentElement(); - if (current.namespace == Namespace.HTML) { - processHtmlEndTag(elementName); + if (currentElement().namespace == Namespace.HTML) { + processEndTagUnderHtmlRules(elementName); return; } if ("br".equals(elementName) || "p".equals(elementName)) { popToHtmlOrIntegrationPoint(); - processHtmlEndTag(elementName); + processEndTagUnderHtmlRules(elementName); return; } // The foreign-content end-tag algorithm walks down from the current // node. A foreign node with the tag name closes, along with every // node above it. At the first HTML node the browser reprocesses the - // token under the HTML rules instead, where an HTML node with the tag - // name closes the same way, but a node in the special category, which - // among foreign elements means an integration point, ends the search - // and the token is ignored. - boolean htmlRules = false; - boolean sawIntegrationPoint = false; + // token under the rules of its current HTML insertion mode instead. for (int i = openElements.size(); --i >= 0;) { OpenElement open = openElements.get(i); - boolean isHtml = open.namespace == Namespace.HTML; - boolean isIntegrationPoint - = open.mathTextIntegrationPoint || open.htmlIntegrationPoint; - if (isHtml) { - htmlRules = true; - } else if (htmlRules && isIntegrationPoint) { + if (open.namespace == Namespace.HTML) { break; } + if (asciiEqualsIgnoreCase(open.elementName, elementName)) { + openElements.subList(i, openElements.size()).clear(); return; } - if (isHtml == htmlRules - && asciiEqualsIgnoreCase(open.elementName, elementName)) { - openElements.subList(i, openElements.size()).clear(); + } + processEndTagUnderHtmlRules(elementName); + } + + /** + * Applies an end tag that a browser processes under the rules of its + * current HTML insertion mode. Only outcomes that follow from the + * tracked elements alone are modeled. Anything that depends on the + * untracked ancestors of the foreign root, on the insertion mode, or on + * the list of active formatting elements makes the context unknown, + * which fails closed: self-closing flags are no longer honored. + */ + private void processEndTagUnderHtmlRules(String elementName) { + if (TABLE_SCOPED_ELEMENT_NAMES.contains(elementName) + || "template".equals(elementName)) { + // Table scope is bounded only by html, table and template, so these + // end tags reach past integration points to untracked ancestors, + // and what they close depends on the insertion mode. + becomeUnknown(); + return; + } + if (IGNORED_HTML_END_TAG_NAMES.contains(elementName)) { + return; + } + boolean anyOther = !SPECIFIC_END_TAG_RULE_NAMES.contains(elementName); + boolean formatting = FORMATTING_ELEMENT_NAMES.contains(elementName); + boolean heading = isHeadingName(elementName); + for (int i = openElements.size(); --i >= 0;) { + OpenElement open = openElements.get(i); + if (open.namespace != Namespace.HTML) { + // Integration points and annotation-xml are in the special + // category and bound every scope. Other foreign elements are + // transparent to both kinds of search. + if (open.special) { return; } + continue; + } + String openName = open.elementName; + if (asciiEqualsIgnoreCase(openName, elementName) + || (heading && isHeadingName(openName))) { + if ("form".equals(elementName)) { + // removes the form element without popping the + // elements above it. + openElements.remove(i); + } else { + openElements.subList(i, openElements.size()).clear(); + } + return; + } + if (anyOther) { + // "Any other end tag" stops at any element in the special + // category. + if (SPECIAL_HTML_ELEMENT_NAMES.contains(openName)) { return; } + continue; + } + if (DEFAULT_SCOPE_BOUNDARY_NAMES.contains(openName) + || ("li".equals(elementName) + && ("ol".equals(openName) || "ul".equals(openName))) + || ("p".equals(elementName) && "button".equals(openName))) { + // Not in scope: the token is ignored, or for

an empty p is + // inserted and closed at once. + return; + } + if (formatting && SPECIAL_HTML_ELEMENT_NAMES.contains(openName)) { + // The adoption agency algorithm restructures the stack around + // this "furthest block", dropping the foreign nodes above it. + becomeUnknown(); return; } - sawIntegrationPoint |= isIntegrationPoint; } - // Nothing tracked matched, so the token now applies to the HTML - // elements below the first foreign root, which are not tracked. No - // HTML element is named svg or math, and an integration point in - // between is special and stops the search, so the browser ignores the - // token in those cases. Otherwise the named element may well be - // open below, in which case the browser closes it and every foreign - // element above it. Assume that it is: the cost of guessing wrong is - // only that self-closing flags stop being honored in the rest of an - // svg or math element whose author wrote a stray end tag, which is - // how those tags were always processed before the flag was honored. - if (isForeignContentRoot(elementName) || sawIntegrationPoint) { + if (openElements.isEmpty() || "form".equals(elementName)) { return; } - openElements.clear(); + // Nothing tracked bounded the search, so whether the token closes the + // whole foreign region depends on the untracked HTML ancestors. + becomeUnknown(); } private boolean processHtmlStartTag( @@ -344,9 +392,15 @@ private boolean processHtmlStartTag( } else if ("math".equals(elementName)) { namespace = Namespace.MATHML; } else { - if (!openElements.isEmpty() - && !HtmlTextEscapingMode.isVoidElement(elementName)) { - push(new OpenElement(elementName, Namespace.HTML, attrs)); + if (!openElements.isEmpty()) { + if (CONTEXT_CHANGING_START_TAG_NAMES.contains(elementName)) { + // Table structure, templates and selects change the insertion + // mode or pop the stack in ways that depend on untracked state. + becomeUnknown(); + } else if (!HtmlTextEscapingMode.isVoidElement(elementName) + && !IGNORED_HTML_START_TAG_NAMES.contains(elementName)) { + push(new OpenElement(elementName, Namespace.HTML, attrs)); + } } // HTML ignores the self-closing flag on ordinary non-void elements. // Void elements are already empty and need no synthetic close event. @@ -359,15 +413,9 @@ private boolean processHtmlStartTag( return selfClosing; } - private void processHtmlEndTag(String elementName) { - for (int i = openElements.size(); --i >= 0;) { - OpenElement open = openElements.get(i); - if (open.namespace != Namespace.HTML) { return; } - if (asciiEqualsIgnoreCase(open.elementName, elementName)) { - openElements.subList(i, openElements.size()).clear(); - return; - } - } + private void becomeUnknown() { + openElements.clear(); + unknown = true; } private void popToHtmlOrIntegrationPoint() { @@ -384,8 +432,7 @@ private void popToHtmlOrIntegrationPoint() { private void push(OpenElement element) { if (openElements.size() == MAX_DEPTH) { - openElements.clear(); - unknown = true; + becomeUnknown(); } else { openElements.add(element); } @@ -425,6 +472,8 @@ private static final class OpenElement { final Namespace namespace; final boolean mathTextIntegrationPoint; final boolean htmlIntegrationPoint; + /** In the special category, which bounds every scope. */ + final boolean special; OpenElement( String elementName, Namespace namespace, List attrs) { @@ -434,6 +483,9 @@ private static final class OpenElement { && MATHML_TEXT_INTEGRATION_POINT_NAMES.contains(elementName); this.htmlIntegrationPoint = isHtmlIntegrationPoint( elementName, namespace, attrs); + this.special = mathTextIntegrationPoint || htmlIntegrationPoint + || (namespace == Namespace.MATHML + && "annotation-xml".equals(elementName)); } } @@ -486,6 +538,79 @@ private static boolean breaksOutOfForeignContent( return false; } + + /** True for h1 through h6, any of which an h1 through h6 end tag closes. */ + private static boolean isHeadingName(String canonElementName) { + if (canonElementName.length() != 2 || canonElementName.charAt(0) != 'h') { + return false; + } + char digit = canonElementName.charAt(1); + return digit >= '1' && digit <= '6'; + } + + /** End tags whose effect is decided by table scope or the insertion mode. */ + private static final Set TABLE_SCOPED_ELEMENT_NAMES + = j8().setOf( + "table", "caption", "tbody", "thead", "tfoot", "tr", "td", "th"); + + /** HTML end tags that never pop the stack. */ + private static final Set IGNORED_HTML_END_TAG_NAMES + = j8().setOf( + "svg", "math", "body", "html", "br", "col", "colgroup", "frame", + "head"); + + /** + * End tags with their own "in body" rules, which search a scope rather + * than stopping at the first element in the special category. + */ + private static final Set SPECIFIC_END_TAG_RULE_NAMES + = j8().setOf( + "address", "article", "aside", "blockquote", "button", "center", + "details", "dialog", "dir", "div", "dl", "fieldset", "figcaption", + "figure", "footer", "header", "hgroup", "listing", "main", "menu", + "nav", "ol", "pre", "search", "section", "summary", "ul", "form", + "p", "li", "dd", "dt", "h1", "h2", "h3", "h4", "h5", "h6", + "a", "b", "big", "code", "em", "font", "i", "nobr", "s", "small", + "strike", "strong", "tt", "u", "applet", "marquee", "object"); + + private static final Set FORMATTING_ELEMENT_NAMES + = j8().setOf( + "a", "b", "big", "code", "em", "font", "i", "nobr", "s", "small", + "strike", "strong", "tt", "u"); + + /** The HTML elements that bound the default scope. */ + private static final Set DEFAULT_SCOPE_BOUNDARY_NAMES + = j8().setOf( + "applet", "caption", "html", "table", "td", "th", "marquee", + "object", "select", "template"); + + /** The HTML elements in the special category. */ + private static final Set SPECIAL_HTML_ELEMENT_NAMES + = j8().setOf( + "address", "applet", "area", "article", "aside", "base", + "basefont", "bgsound", "blockquote", "body", "br", "button", + "caption", "center", "col", "colgroup", "dd", "details", "dir", + "div", "dl", "dt", "embed", "fieldset", "figcaption", "figure", + "footer", "form", "frame", "frameset", "h1", "h2", "h3", "h4", + "h5", "h6", "head", "header", "hgroup", "hr", "html", "iframe", + "img", "input", "keygen", "li", "link", "listing", "main", + "marquee", "menu", "meta", "nav", "noembed", "noframes", + "noscript", "object", "ol", "p", "param", "plaintext", "pre", + "script", "search", "section", "select", "source", "style", + "summary", "table", "tbody", "td", "template", "textarea", + "tfoot", "th", "thead", "title", "tr", "track", "ul", "wbr", + "xmp"); + + /** Start tags inside foreign content whose effect depends on the mode. */ + private static final Set CONTEXT_CHANGING_START_TAG_NAMES + = j8().setOf( + "table", "caption", "col", "colgroup", "tbody", "thead", "tfoot", + "tr", "td", "th", "template", "select", "frameset"); + + /** Start tags that "in body" ignores or merges rather than inserting. */ + private static final Set IGNORED_HTML_START_TAG_NAMES + = j8().setOf("html", "body", "head", "frame"); + private static final Set MATHML_TEXT_INTEGRATION_POINT_NAMES = j8().setOf("mi", "mo", "mn", "ms", "mtext"); diff --git a/owasp-java-html-sanitizer/src/test/java/org/owasp/html/HtmlSanitizerTest.java b/owasp-java-html-sanitizer/src/test/java/org/owasp/html/HtmlSanitizerTest.java index fcbd72e1..1c9d8e64 100644 --- a/owasp-java-html-sanitizer/src/test/java/org/owasp/html/HtmlSanitizerTest.java +++ b/owasp-java-html-sanitizer/src/test/java/org/owasp/html/HtmlSanitizerTest.java @@ -972,9 +972,9 @@ void testForeignContentEndTagsUpdateTheMatchingContext() { /** * Issue #457. An end tag that names none of the open foreign elements - * may close an HTML ancestor of the foreign root, which is not tracked, - * so the parser is assumed to be back in HTML content unless the browser - * would ignore the tag. + * may close an HTML ancestor of the foreign root, which is not tracked. + * The context is then unknown and the sanitizer falls back to the HTML + * rules, unless the browser would ignore the tag. */ @Test void testEndTagsOfHtmlAncestorsEndForeignContent() { @@ -1134,6 +1134,154 @@ public void closeTag(String elementName) { events); } + /** + * Issue #461. Browsers process the end tags of table structure with + * table scope, which no integration point bounds, so they close the + * foreign content around a cell along with the cell. The tracker cannot + * see the table, so it fails closed for the rest of the input. + */ + @Test + void testTableScopeEndTagsEndForeignContent() { + PolicyFactory p = foreignContentPolicy(); + for (String endTag + : new String[] { "", "", "", "" }) { + assertEquals( + "", + p.sanitize( + "` and the rest of the table structure), - `` through ``, ``, a formatting end tag across a - special element, a stray end tag that may name an ancestor of the - foreign root, and table, `
" + endTag + + "hidden"), + endTag); + } + assertEquals( + "", + p.sanitize( + "" + + "hidden")); + assertEquals( + "", + p.sanitize("
hidden")); + assertEquals( + "", + p.sanitize("
hidden")); + assertEquals( + "" + + "", + p.sanitize( + "
" + + "
hidden")); + // The context stays unknown, so a later self-closing tag is not honored + // either. + assertEquals( + "" + + "x", + p.sanitize( + "
x")); + // Table structure inside an integration point implies elements the + // tracker does not see, so it fails closed at the start tag. + assertEquals( + "", + p.sanitize( + "" + + "hidden")); + assertEquals( + "", + p.sanitize("" + + "hidden")); + // Without a special element in the way the end tags match, the + // integration point closes, and the flag is honored again. + assertEquals( + "" + + "x", + p.sanitize( + "" + + "x")); + // Any h1 through h6 end tag closes an open heading. + assertEquals( + "

", + p.sanitize("

hidden")); + // removes the form without closing what it holds. + assertEquals( + "
", + p.sanitize( + "
" + + "hidden")); + assertEquals( + "
shown
" + + "
", + p.sanitize( + "
shown")); + // The adoption agency algorithm rebuilds the stack around a special + // element and drops the foreign nodes above it. + assertEquals( + "
" + + "
", + p.sanitize( + "
" + + "hidden")); + // When a special element blocks the end tag, the foreign element above + // it is still the current node, where the flag is honored. + assertEquals( + "
shown" + + "
", + p.sanitize( + "
shown")); + } + + /** + * Issue #461. After a stray end tag the tracker cannot tell whether the + * browser left the foreign content, and the next svg start tag inside + * MathML is a MathML element whose foreignObject is not an integration + * point, so the tracker stays unknown rather than starting over. + */ + @Test + void testStrayEndTagLeavesForeignContentContextUnknown() { + PolicyFactory p = foreignContentPolicy(); + assertEquals( + "
" + + "
", + p.sanitize( + "
" + + "
hidden")); + assertEquals( + "x", + p.sanitize("x")); + // A self-closing root is still empty in every context. + assertEquals( + "x", + p.sanitize("x")); + } + /** The bounded context tracker falls back to suppressing dropped content. */ @Test void testDeepForeignContentContextFailsClosed() { @@ -1150,7 +1298,8 @@ private static PolicyFactory foreignContentPolicy() { .allowElements( "svg", "math", "path", "g", "rect", "clipPath", "foreignObject", "desc", "annotation-xml", "textArea", "textarea", "mi", "mrow", - "mglyph", "a", "div", "p", "font", "br", "style", "title") + "mglyph", "a", "div", "p", "font", "br", "style", "title", + "cite", "kbd", "b", "h2", "form") .allowAttributes("width", "height", "viewBox").onElements("svg") .allowAttributes("id", "opacity", "d").onElements("path") .allowAttributes("href").onElements("a") From 5a5c0c6a2f30106baa828d6b61ec6a8258b8babb Mon Sep 17 00:00:00 2001 From: Jim Manico Date: Thu, 10 Sep 2026 09:19:11 -1000 Subject: [PATCH 2/4] Track HTML stack mutations in foreign content --- change_log.md | 23 +- .../java/org/owasp/html/HtmlSanitizer.java | 444 +++++++++++++++++- .../org/owasp/html/HtmlSanitizerTest.java | 116 ++++- 3 files changed, 541 insertions(+), 42 deletions(-) diff --git a/change_log.md b/change_log.md index 5322f3c0..0ae3de98 100644 --- a/change_log.md +++ b/change_log.md @@ -2,18 +2,17 @@ Most recent at top. * Next release - * The context tracker behind self-closing SVG and MathML tags now fails - closed when an end tag reaches the HTML rules with an effect it cannot - derive from the elements it tracks: end tags processed with table - scope (`
`, `