diff --git a/change_log.md b/change_log.md
index bdf3f808..031fe183 100644
--- a/change_log.md
+++ b/change_log.md
@@ -51,6 +51,30 @@ Most recent at top.
backslash as a slash, so `\\example.org/` names the same authority
as `//example.org/`. Policies that allow both web protocols keep
accepting these values, as they accept `//example.org/`. Issue #453.
+ * The context tracker behind self-closing SVG and MathML tags now follows
+ the HTML start- and end-tag rules that can change the open-element
+ stack inside an integration point, including p, list, heading, button,
+ form, formatting, select and option rules. It also tracks the form
+ pointer and inherited table or cell mode, and uses the current parser's
+ element categories rather than legacy void-element classifications.
+ When an outcome still depends on untracked table or template state or
+ on the active formatting list, it fails closed so only `` and
+ `` close themselves. Well-formed select and empty-table HTML
+ islands no longer make later SVG unnecessarily use that fallback.
+ Issue #461.
+ * The same tracker now closes p for `xmp`, honors only the first of
+ duplicate `type` attributes on `input`, fails closed for `table` after
+ an open p (whose fate depends on quirks mode) and for `search` (which
+ the specification and Chrome categorize differently), and follows
+ untracked cells, captions, sections and nested tables so that a
+ mismatched cell or section end tag is ignored as browsers ignore it.
+ Well-formed nested tables and captions no longer make later SVG use
+ the fallback. Issue #461.
+ * The same tracker now also fails closed when a walk reaches an open
+ `dialog`, which the specification and Chrome categorize differently in
+ the special category, exactly as it already did for `search`. Without
+ this a later self-closing `` was honored, exposing text a
+ spec-compliant parser keeps inside the HTML `object`. 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..54e3f700 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
@@ -242,9 +242,37 @@ private static final class ForeignContentContext {
/** Elements from the first open foreign root through the current node. */
private final List openElements = new ArrayList<>();
+ /** The form pointer is not bounded by an integration point. */
+ private boolean formElementPointerSet;
+
+ /** The form pointer's target, when that element is in the tracked region. */
+ private @Nullable OpenElement trackedFormElement;
+
+ /** A table inserted in known in-body mode, before any child tag. */
+ private @Nullable OpenElement simpleTable;
+
+ /** The mode to restore when {@link #simpleTable} closes. */
+ private HtmlInsertionMode simpleTableReturnMode
+ = HtmlInsertionMode.IN_BODY;
+
+ /** The HTML mode that remains in force while foreign rules run. */
+ private HtmlInsertionMode htmlInsertionMode = HtmlInsertionMode.IN_BODY;
+
+ /** Bound the memory spent on untracked tables that never close. */
+ private static final int MAX_UNTRACKED_TABLES = 32;
+
/**
- * True once the bounded stack is exhausted. The legacy HTML behavior is
- * the conservative fallback for ordinary tags from that point onward.
+ * The tables open below the tracked region, innermost last. Each is in
+ * table scope, since a template makes the context unknown.
+ */
+ private final List untrackedTables = new ArrayList<>();
+
+ /**
+ * 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;
@@ -280,72 +308,296 @@ && breaksOutOfForeignContent(elementName, attrs)) {
/** Updates the context using the foreign-content or HTML end-tag rules. */
void processEndTag(String elementName) {
- if (unknown || openElements.isEmpty()) { return; }
+ if (unknown) { return; }
+ if (openElements.isEmpty()) {
+ if ("form".equals(elementName)) {
+ formElementPointerSet = false;
+ trackedFormElement = null;
+ }
+ trackUntrackedHtmlEndTag(elementName);
+ 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 (simpleTable != null) {
+ if ("table".equals(elementName)
+ && currentElement() == simpleTable) {
+ openElements.remove(openElements.size() - 1);
+ simpleTable = null;
+ htmlInsertionMode = simpleTableReturnMode;
+ } else {
+ becomeUnknown();
+ }
+ return;
+ }
+ 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;
+ }
+ if ("form".equals(elementName)) {
+ OpenElement form = trackedFormElement;
+ formElementPointerSet = false;
+ trackedFormElement = null;
+ if (form != null) {
+ int formIndex = openElements.indexOf(form);
+ if (formIndex >= 0 && isInDefaultScope(formIndex)) {
+ // Outside template contents, removes the form without
+ // popping the elements above it.
+ generateImpliedEndTags(null);
+ openElements.remove(formIndex);
+ }
+ }
+ 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 (!popTrackedElementsFrom(
+ i,
+ ACTIVE_FORMATTING_MARKER_ELEMENT_NAMES.contains(elementName),
+ formatting ? open : null)) {
+ return;
+ }
+ return;
+ }
+ if (anyOther) {
+ // "Any other end tag" stops at any element in the special
+ // category.
+ if (AMBIGUOUSLY_SPECIAL_HTML_ELEMENT_NAMES.contains(openName)) {
+ becomeUnknown();
+ return;
+ }
+ 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()) {
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(
String elementName, List attrs, boolean selfClosing) {
+ if (simpleTable != null) {
+ // A child start tag is where the in-table modes start implying or
+ // foster-parenting elements. Keep the empty-table case exact and
+ // fail closed for the rest.
+ becomeUnknown();
+ return selfClosing && isForeignContentRoot(elementName);
+ }
+
Namespace namespace;
if ("svg".equals(elementName)) {
namespace = Namespace.SVG;
} else if ("math".equals(elementName)) {
namespace = Namespace.MATHML;
} else {
- if (!openElements.isEmpty()
- && !HtmlTextEscapingMode.isVoidElement(elementName)) {
+ if (openElements.isEmpty()) {
+ trackUntrackedHtmlStartTag(elementName);
+ if (unknown) { return false; }
+ }
+ if (UNMODELED_CONTEXT_CHANGING_START_TAG_NAMES.contains(elementName)) {
+ becomeUnknown();
+ return false;
+ }
+ if ("form".equals(elementName)) {
+ if (htmlInsertionMode == HtmlInsertionMode.IN_TABLE) {
+ // "In table" inserts a new form and immediately pops it.
+ if (!formElementPointerSet) {
+ formElementPointerSet = true;
+ trackedFormElement = null;
+ }
+ return false;
+ }
+ processFormStartTag(attrs);
+ return false;
+ }
+ if (openElements.isEmpty()) {
+ return false;
+ }
+ if (TABLE_STRUCTURE_START_TAG_NAMES.contains(elementName)) {
+ if (htmlInsertionMode != HtmlInsertionMode.IN_BODY) {
+ becomeUnknown();
+ }
+ return false;
+ }
+ if (P_CLOSING_START_TAG_NAMES.contains(elementName)
+ || "pre".equals(elementName)
+ || "listing".equals(elementName)
+ || "plaintext".equals(elementName)
+ || "xmp".equals(elementName)) {
+ if (!closePIfInButtonScope()) { return false; }
+ } else if (isHeadingName(elementName)) {
+ if (!closePIfInButtonScope()) { return false; }
+ OpenElement current = currentElement();
+ if (current.namespace == Namespace.HTML
+ && isHeadingName(current.elementName)) {
+ openElements.remove(openElements.size() - 1);
+ }
+ } else if ("li".equals(elementName)) {
+ if (!closeListOrDescriptionItemForStart(true)
+ || !closePIfInButtonScope()) {
+ return false;
+ }
+ } else if ("dd".equals(elementName) || "dt".equals(elementName)) {
+ if (!closeListOrDescriptionItemForStart(false)
+ || !closePIfInButtonScope()) {
+ return false;
+ }
+ } else if ("button".equals(elementName)) {
+ int buttonIndex = findHtmlElementInDefaultScope("button", true);
+ if (buttonIndex >= 0) {
+ if (!popTrackedElementsFrom(buttonIndex, false, null)) {
+ return false;
+ }
+ }
+ } else if ("a".equals(elementName)) {
+ if (findOpenHtmlElement("a") >= 0) {
+ becomeUnknown();
+ return false;
+ }
+ } else if ("nobr".equals(elementName)) {
+ if (findHtmlElementInDefaultScope("nobr", false) >= 0) {
+ becomeUnknown();
+ return false;
+ }
+ } else if ("select".equals(elementName)) {
+ int selectIndex = findHtmlElementInDefaultScope("select", false);
+ if (selectIndex >= 0) {
+ // A nested select start tag is ignored after popping the first.
+ if (!popTrackedElementsFrom(selectIndex, false, null)) {
+ return false;
+ }
+ return false;
+ }
+ } else if ("option".equals(elementName)) {
+ if (findHtmlElementInDefaultScope("select", false) >= 0) {
+ generateImpliedEndTags("optgroup");
+ } else if (isCurrentHtmlElement("option")) {
+ openElements.remove(openElements.size() - 1);
+ }
+ } else if ("optgroup".equals(elementName)) {
+ if (findHtmlElementInDefaultScope("select", false) >= 0) {
+ generateImpliedEndTags(null);
+ } else if (isCurrentHtmlElement("option")) {
+ openElements.remove(openElements.size() - 1);
+ }
+ } else if ("input".equals(elementName)) {
+ if (htmlInsertionMode == HtmlInsertionMode.IN_TABLE
+ && hasHiddenInputType(attrs)) {
+ return false;
+ }
+ int selectIndex = findHtmlElementInDefaultScope("select", false);
+ if (selectIndex >= 0) {
+ if (!popTrackedElementsFrom(selectIndex, false, null)) {
+ return false;
+ }
+ }
+ } else if ("hr".equals(elementName)) {
+ if (!closePIfInButtonScope()) { return false; }
+ if (findHtmlElementInDefaultScope("select", false) >= 0) {
+ generateImpliedEndTags(null);
+ }
+ } else if (UNMODELED_HTML_START_TAG_NAMES.contains(elementName)) {
+ becomeUnknown();
+ return false;
+ } else if ("image".equals(elementName)) {
+ // The in-body rules rewrite image to the void img element.
+ return false;
+ } else if ("table".equals(elementName)) {
+ if (htmlInsertionMode == HtmlInsertionMode.IN_TABLE) {
+ becomeUnknown();
+ return false;
+ }
+ if (findHtmlElementInDefaultScope("p", true) >= 0) {
+ // Only a no-quirks document closes the p, and the sanitizer
+ // cannot know the mode of the document that embeds its output.
+ becomeUnknown();
+ return false;
+ }
+ OpenElement table = new OpenElement(
+ elementName, Namespace.HTML, attrs);
+ push(table);
+ if (!unknown) {
+ simpleTable = table;
+ simpleTableReturnMode = htmlInsertionMode;
+ htmlInsertionMode = HtmlInsertionMode.IN_TABLE;
+ }
+ return false;
+ }
+
+ if (!HTML_TREE_BUILDER_VOID_ELEMENT_NAMES.contains(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.
@@ -359,15 +611,232 @@ private boolean processHtmlStartTag(
return selfClosing;
}
- private void processHtmlEndTag(String elementName) {
+ private @Nullable UntrackedTable currentUntrackedTable() {
+ int size = untrackedTables.size();
+ return size != 0 ? untrackedTables.get(size - 1) : null;
+ }
+
+ /** Derives the HTML insertion mode from the innermost untracked table. */
+ private void syncInsertionMode() {
+ UntrackedTable table = currentUntrackedTable();
+ if (table == null) {
+ htmlInsertionMode = HtmlInsertionMode.IN_BODY;
+ } else if (table.cellName != null) {
+ // The cell and caption modes hand everything else to the in-body
+ // rules, but hand table structure to the table rules.
+ htmlInsertionMode = HtmlInsertionMode.IN_CELL;
+ } else {
+ htmlInsertionMode = HtmlInsertionMode.IN_TABLE;
+ }
+ }
+
+ private void trackUntrackedHtmlStartTag(String elementName) {
+ UntrackedTable table = currentUntrackedTable();
+ if ("table".equals(elementName)) {
+ if (table != null && table.cellName == null) {
+ // The table modes pop the open table before reprocessing the
+ // token; a cell or caption nests the new table instead.
+ untrackedTables.remove(untrackedTables.size() - 1);
+ }
+ if (untrackedTables.size() == MAX_UNTRACKED_TABLES) {
+ becomeUnknown();
+ return;
+ }
+ untrackedTables.add(new UntrackedTable());
+ } else if (table == null) {
+ return;
+ } else if ("td".equals(elementName) || "th".equals(elementName)) {
+ table.cellName = elementName;
+ if (table.sectionName == null) { table.sectionName = "tbody"; }
+ } else if ("caption".equals(elementName)) {
+ table.cellName = elementName;
+ table.sectionName = null;
+ } else if ("tr".equals(elementName)) {
+ table.cellName = null;
+ if (table.sectionName == null) { table.sectionName = "tbody"; }
+ } else if ("tbody".equals(elementName) || "thead".equals(elementName)
+ || "tfoot".equals(elementName)) {
+ table.cellName = null;
+ table.sectionName = elementName;
+ } else if ("col".equals(elementName) || "colgroup".equals(elementName)) {
+ table.cellName = null;
+ table.sectionName = null;
+ }
+ syncInsertionMode();
+ }
+
+ private void trackUntrackedHtmlEndTag(String elementName) {
+ UntrackedTable table = currentUntrackedTable();
+ if (table == null) { return; }
+ if ("table".equals(elementName)) {
+ untrackedTables.remove(untrackedTables.size() - 1);
+ } else if ("td".equals(elementName) || "th".equals(elementName)
+ || "caption".equals(elementName)) {
+ // Ignored unless it names the open cell or caption.
+ if (elementName.equals(table.cellName)) { table.cellName = null; }
+ } else if ("tr".equals(elementName)) {
+ // A caption ignores it; a cell closes along with the row.
+ if (!"caption".equals(table.cellName)) { table.cellName = null; }
+ } else if ("tbody".equals(elementName) || "thead".equals(elementName)
+ || "tfoot".equals(elementName)) {
+ // A caption ignores it, and so does a cell in another section.
+ if (!"caption".equals(table.cellName)
+ && elementName.equals(table.sectionName)) {
+ table.cellName = null;
+ table.sectionName = null;
+ }
+ }
+ syncInsertionMode();
+ }
+
+ private void processFormStartTag(List attrs) {
+ if (formElementPointerSet) { return; }
+ formElementPointerSet = true;
+ if (openElements.isEmpty()) { return; }
+ if (!closePIfInButtonScope()) { return; }
+ OpenElement form = new OpenElement("form", Namespace.HTML, attrs);
+ push(form);
+ if (!unknown) { trackedFormElement = form; }
+ }
+
+ private boolean closePIfInButtonScope() {
+ int pIndex = findHtmlElementInDefaultScope("p", true);
+ if (pIndex >= 0) {
+ return popTrackedElementsFrom(pIndex, false, null);
+ }
+ return true;
+ }
+
+ private boolean closeListOrDescriptionItemForStart(boolean listItem) {
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();
+ if (open.namespace == Namespace.HTML
+ && (listItem
+ ? "li".equals(open.elementName)
+ : "dd".equals(open.elementName)
+ || "dt".equals(open.elementName))) {
+ return popTrackedElementsFrom(i, false, null);
+ }
+ if (open.namespace == Namespace.HTML
+ && AMBIGUOUSLY_SPECIAL_HTML_ELEMENT_NAMES.contains(
+ open.elementName)) {
+ becomeUnknown();
+ return false;
+ }
+ if (isSpecial(open)
+ && !isHtmlElement(open, "address")
+ && !isHtmlElement(open, "div")
+ && !isHtmlElement(open, "p")) {
+ return true;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Pops a known suffix, or fails closed if doing so leaves formatting
+ * elements only in the active formatting list. A later start tag could
+ * reconstruct those elements and put Chrome back in HTML content while
+ * this bounded tracker believed that the current node was foreign.
+ */
+ private boolean popTrackedElementsFrom(
+ int fromIndex,
+ boolean allFormattingEntriesAreCleared,
+ @Nullable OpenElement oneFormattingEntryRemoved) {
+ if (!allFormattingEntriesAreCleared) {
+ for (int i = openElements.size(); --i >= fromIndex;) {
+ OpenElement open = openElements.get(i);
+ if (open.namespace == Namespace.HTML
+ && FORMATTING_ELEMENT_NAMES.contains(open.elementName)
+ && open != oneFormattingEntryRemoved) {
+ becomeUnknown();
+ return false;
+ }
+ }
+ }
+ openElements.subList(fromIndex, openElements.size()).clear();
+ return true;
+ }
+
+ private void generateImpliedEndTags(@Nullable String except) {
+ while (!openElements.isEmpty()) {
+ OpenElement current = currentElement();
+ if (current.namespace != Namespace.HTML
+ || !IMPLIED_END_TAG_NAMES.contains(current.elementName)
+ || current.elementName.equals(except)) {
return;
}
+ openElements.remove(openElements.size() - 1);
+ }
+ }
+
+ private int findHtmlElementInDefaultScope(
+ String elementName, boolean buttonScope) {
+ for (int i = openElements.size(); --i >= 0;) {
+ OpenElement open = openElements.get(i);
+ if (isHtmlElement(open, elementName)) { return i; }
+ if (open.namespace != Namespace.HTML) {
+ if (open.special) { return -1; }
+ } else if (DEFAULT_SCOPE_BOUNDARY_NAMES.contains(open.elementName)
+ || (buttonScope && "button".equals(open.elementName))) {
+ return -1;
+ }
+ }
+ return -1;
+ }
+
+ private boolean isInDefaultScope(int targetIndex) {
+ for (int i = openElements.size(); --i > targetIndex;) {
+ OpenElement open = openElements.get(i);
+ if (open.namespace != Namespace.HTML) {
+ if (open.special) { return false; }
+ } else if (DEFAULT_SCOPE_BOUNDARY_NAMES.contains(open.elementName)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private int findOpenHtmlElement(String elementName) {
+ for (int i = openElements.size(); --i >= 0;) {
+ if (isHtmlElement(openElements.get(i), elementName)) { return i; }
+ }
+ return -1;
+ }
+
+ private boolean isCurrentHtmlElement(String elementName) {
+ return isHtmlElement(currentElement(), elementName);
+ }
+
+ private static boolean isHtmlElement(
+ OpenElement open, String elementName) {
+ return open.namespace == Namespace.HTML
+ && elementName.equals(open.elementName);
+ }
+
+ private static boolean isSpecial(OpenElement open) {
+ return open.namespace == Namespace.HTML
+ ? SPECIAL_HTML_ELEMENT_NAMES.contains(open.elementName)
+ : open.special;
+ }
+
+ private static boolean hasHiddenInputType(List attrs) {
+ for (int i = 0; i + 1 < attrs.size(); i += 2) {
+ if ("type".equals(attrs.get(i))) {
+ // The tokenizer drops all but the first of duplicate attributes.
+ return asciiEqualsIgnoreCase("hidden", attrs.get(i + 1));
+ }
}
+ return false;
+ }
+
+ private void becomeUnknown() {
+ openElements.clear();
+ formElementPointerSet = false;
+ trackedFormElement = null;
+ simpleTable = null;
+ untrackedTables.clear();
+ unknown = true;
}
private void popToHtmlOrIntegrationPoint() {
@@ -384,8 +853,7 @@ private void popToHtmlOrIntegrationPoint() {
private void push(OpenElement element) {
if (openElements.size() == MAX_DEPTH) {
- openElements.clear();
- unknown = true;
+ becomeUnknown();
} else {
openElements.add(element);
}
@@ -413,6 +881,12 @@ private static boolean usesHtmlRulesForStartTag(
}
}
+ private enum HtmlInsertionMode {
+ IN_BODY,
+ IN_TABLE,
+ IN_CELL,
+ }
+
private enum Namespace {
HTML,
SVG,
@@ -425,6 +899,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,9 +910,20 @@ 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));
}
}
+ /** A table open below the tracked region, and the part of it being filled. */
+ private static final class UntrackedTable {
+ /** td, th or caption while one is open. */
+ @Nullable String cellName;
+ /** tbody, thead or tfoot while one is open. */
+ @Nullable String sectionName;
+ }
+
private static boolean isForeignContentRoot(String canonElementName) {
return "svg".equals(canonElementName) || "math".equals(canonElementName);
}
@@ -486,6 +973,123 @@ 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", "select", "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");
+
+ /** Elements whose end tags clear the active formatting list to a marker. */
+ private static final Set ACTIVE_FORMATTING_MARKER_ELEMENT_NAMES
+ = j8().setOf("applet", "marquee", "object");
+
+ /** 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",
+ "dialog", "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");
+
+ /**
+ * Elements the specification puts in the special category but current
+ * Chrome does not, so a walk that reaches one has an uncertain outcome.
+ * {@code dialog} and {@code search} are both special in the WHATWG parsing
+ * algorithm but absent from Chrome's special-node set.
+ */
+ private static final Set AMBIGUOUSLY_SPECIAL_HTML_ELEMENT_NAMES
+ = j8().setOf("dialog", "search");
+
+ /** Start tags whose HTML stack effect this bounded tracker cannot derive. */
+ private static final Set UNMODELED_CONTEXT_CHANGING_START_TAG_NAMES
+ = j8().setOf("template", "frameset");
+
+ /** Ruby starts generate implied end tags using state outside this tracker. */
+ private static final Set UNMODELED_HTML_START_TAG_NAMES
+ = j8().setOf("rb", "rtc", "rp", "rt");
+
+ /** Start tags that close a p element in button scope before insertion. */
+ private static final Set P_CLOSING_START_TAG_NAMES
+ = j8().setOf(
+ "address", "article", "aside", "blockquote", "center", "details",
+ "dialog", "dir", "div", "dl", "fieldset", "figcaption", "figure",
+ "footer", "header", "hgroup", "main", "menu", "nav", "ol", "p",
+ "search", "section", "summary", "ul");
+
+ private static final Set IMPLIED_END_TAG_NAMES
+ = j8().setOf(
+ "dd", "dt", "li", "optgroup", "option", "p", "rb", "rp", "rt",
+ "rtc");
+
+ private static final Set TABLE_STRUCTURE_START_TAG_NAMES
+ = j8().setOf(
+ "caption", "col", "colgroup", "tbody", "thead", "tfoot", "tr",
+ "td", "th");
+
+ /** Start tags the current HTML tree builder inserts and immediately pops. */
+ private static final Set HTML_TREE_BUILDER_VOID_ELEMENT_NAMES
+ = j8().setOf(
+ "area", "base", "basefont", "bgsound", "br", "embed", "hr",
+ "img", "input", "keygen", "link", "meta", "param", "source",
+ "track", "wbr");
+
+ /** 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", "caption", "col", "colgroup",
+ "tbody", "thead", "tfoot", "tr", "td", "th");
+
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 99969052..78981ec8 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
@@ -1059,9 +1059,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() {
@@ -1221,6 +1221,457 @@ 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 can close the
+ * foreign content around a cell along with the cell.
+ */
+ @Test
+ void testTableScopeEndTagsEndForeignContent() {
+ PolicyFactory p = foreignContentPolicy();
+ for (String endTag
+ : new String[] { "", "", "", "" }) {
+ assertEquals(
+ "",
+ p.sanitize(
+ "
hidden"));
+ // This table began below the tracked foreign root, so the context stays
+ // unknown after the end tag.
+ assertEquals(
+ ""
+ + "x",
+ p.sanitize(
+ "
x"));
+ // Table structure inside an integration point follows the inherited
+ // table insertion mode, including implied elements and cell closing.
+ assertEquals(
+ "",
+ p.sanitize(
+ "