diff --git a/change_log.md b/change_log.md
index 73047306..541b88a1 100644
--- a/change_log.md
+++ b/change_log.md
@@ -2,6 +2,17 @@
Most recent at top.
* Next release
+ * An element an `ElementPolicy` renames is judged for text by the name
+ the author wrote, which is the name `allowElements`, `allowTextIn` and
+ `disallowTextIn` take, so `span` renamed to `div` keeps its text
+ whether or not `div` is allowed in its own right (#445). A rename
+ into an element whose content a browser reads literally, such as
+ `style`, still needs `allowTextIn` on that name. A void element
+ renamed to one that is not, such as `br` to `span`, is closed at once
+ rather than left open until its parent closes, which also kept the
+ renamed elements from nesting past the balancer's limit (#450). An
+ element renamed to a void one no longer lets its close tag end an outer
+ element of the same name.
* Ordinary script and style text survives the filter on kept
literal-content elements. A tag now needs a well-formed name -- an
ASCII letter and then letters, digits, `-`, `_`, `:` or `.` -- so
diff --git a/owasp-java-html-sanitizer/src/main/java/org/owasp/html/ElementAndAttributePolicyBasedSanitizerPolicy.java b/owasp-java-html-sanitizer/src/main/java/org/owasp/html/ElementAndAttributePolicyBasedSanitizerPolicy.java
index a98e1622..2d0d64fc 100644
--- a/owasp-java-html-sanitizer/src/main/java/org/owasp/html/ElementAndAttributePolicyBasedSanitizerPolicy.java
+++ b/owasp-java-html-sanitizer/src/main/java/org/owasp/html/ElementAndAttributePolicyBasedSanitizerPolicy.java
@@ -68,8 +68,9 @@ class ElementAndAttributePolicyBasedSanitizerPolicy
*
* While a document is open, this is the gate {@link #openElementStack}
* implies. Text belongs to the nearest enclosing element the policy kept,
- * and is emitted only if that element is an allowed text container whose
- * input name is not one text was disallowed in. A dropped element between
+ * and is emitted only if that element is an allowed text container under
+ * the name the author wrote, and, where the policy emitted it under a name
+ * a browser reads literally, under that name too. A dropped element between
* the text and that container is not a container in the output, so it does
* not decide -- unless its content is never meant to be read as text
* ({@link #SKIPPABLE_ELEMENT_CONTENT}) or text in it was disallowed, either
@@ -113,7 +114,9 @@ class ElementAndAttributePolicyBasedSanitizerPolicy
private String literalTextTail = "";
/**
* Alternating input names and adjusted names of elements opened by the
- * caller.
+ * caller, for the input names a close tag can come for: the ones that are
+ * not void. The adjusted name is null where there is nothing to close in
+ * the output, because the element was dropped or emitted as a void one.
*/
private final List openElementStack = new ArrayList<>();
/**
@@ -857,27 +860,53 @@ void writeOpenTag(
ElementAndAttributePolicies policies, String adjustedElementName,
List attrs) {
outputElementNameForLastOpenTag = adjustedElementName;
- if (!HtmlTextEscapingMode.isVoidElement(adjustedElementName)) {
- push(policies.elementName, adjustedElementName);
- // A kept element is the container for the text inside it. It is judged
- // by the name it was kept under, and by the name the author wrote when
- // text was disallowed in that.
- skipText = !allowedTextContainers.contains(adjustedElementName)
- || disallowedTextContainers.contains(policies.elementName);
- boolean enteringKeptCdata = isLiteralContentElement(adjustedElementName)
- && allowedTextContainers.contains(adjustedElementName);
- if (!inKeptCdataElement && enteringKeptCdata) {
- keptCdataElementName = adjustedElementName;
- literalTextTail = "";
+ String elementName = policies.elementName;
+ // Whether a close tag will come for this element depends on the name the
+ // author wrote, which is the one the lexer and the tag balancer see, not
+ // on the name the policy emitted it under. The stack follows suit, so
+ // that every entry on it is one a close tag can pop.
+ if (HtmlTextEscapingMode.isVoidElement(elementName)) {
+ out.openTag(adjustedElementName, attrs);
+ if (!HtmlTextEscapingMode.isVoidElement(adjustedElementName)) {
+ // Renamed to an element that needs closing, which nothing upstream
+ // will do: closed at once, so it does not swallow what follows.
+ out.closeTag(adjustedElementName);
}
- inKeptCdataElement = inKeptCdataElement || enteringKeptCdata;
- // Judged before this element is in foreign content itself: a browser
- // parses the content of an svg or math element as markup, but the
- // element's own tag sits in whatever contains it.
- inForeignContent = inForeignContent
- || HtmlStreamRenderer.FOREIGN_CONTENT_ROOT_ELEMENT_NAMES.contains(
- adjustedElementName);
+ return;
}
+ if (HtmlTextEscapingMode.isVoidElement(adjustedElementName)) {
+ // Renamed to a void element. The close tag that comes for the input
+ // name has nothing to close in the output, and the text between is
+ // not inside the element there, so the gate stays as it was, as after
+ // a dropped element.
+ push(elementName, null);
+ skipText = skipText || suppressesTextWhenDropped(elementName);
+ out.openTag(adjustedElementName, attrs);
+ return;
+ }
+ push(elementName, adjustedElementName);
+ // A kept element is the container for the text inside it. Whether it may
+ // hold text was said of the name the author wrote, which is the name the
+ // builder's methods take, so that is the name judged. An element the
+ // policy emits under a name a browser reads literally, such as style, is
+ // held to the same bar as one written under that name: text in it needs
+ // allowTextIn on that name too.
+ boolean literal = isLiteralContentElement(adjustedElementName);
+ skipText = !allowedTextContainers.contains(elementName)
+ || disallowedTextContainers.contains(elementName)
+ || (literal && !allowedTextContainers.contains(adjustedElementName));
+ boolean enteringKeptCdata = literal && !skipText;
+ if (!inKeptCdataElement && enteringKeptCdata) {
+ keptCdataElementName = adjustedElementName;
+ literalTextTail = "";
+ }
+ inKeptCdataElement = inKeptCdataElement || enteringKeptCdata;
+ // Judged before this element is in foreign content itself: a browser
+ // parses the content of an svg or math element as markup, but the
+ // element's own tag sits in whatever contains it.
+ inForeignContent = inForeignContent
+ || HtmlStreamRenderer.FOREIGN_CONTENT_ROOT_ELEMENT_NAMES.contains(
+ adjustedElementName);
out.openTag(adjustedElementName, attrs);
}
diff --git a/owasp-java-html-sanitizer/src/main/java/org/owasp/html/HtmlPolicyBuilder.java b/owasp-java-html-sanitizer/src/main/java/org/owasp/html/HtmlPolicyBuilder.java
index 8ce1d77d..31994eb2 100644
--- a/owasp-java-html-sanitizer/src/main/java/org/owasp/html/HtmlPolicyBuilder.java
+++ b/owasp-java-html-sanitizer/src/main/java/org/owasp/html/HtmlPolicyBuilder.java
@@ -256,6 +256,17 @@ public HtmlPolicyBuilder disallowElements(String... elementNames) {
private static HtmlElementTables METADATA = HtmlElementTables.get();
/**
* Allow the given elements with the given policy.
+ *
+ * Whether text may appear inside an element the policy renames follows the
+ * name given here, which {@link #allowTextIn} and {@link #disallowTextIn}
+ * take as well, not the name the policy returns: a policy that turns
+ * {@code span} into {@code div} keeps the text of the span whether or not
+ * {@code div} is allowed in its own right. The one exception is a rename
+ * into an element whose content a browser reads literally, such as
+ * {@code style} or {@code script}, which keeps text only if that name was
+ * also passed to {@code allowTextIn}, as it would have to be if written.
+ * A void element renamed to one that is not void, such as {@code br} to
+ * {@code span}, is closed at once, since no close tag will come for it.
*
* @param policy May remove or add attributes, change the element name, or
* deny the element.
diff --git a/owasp-java-html-sanitizer/src/test/java/org/owasp/html/HtmlPolicyBuilderTest.java b/owasp-java-html-sanitizer/src/test/java/org/owasp/html/HtmlPolicyBuilderTest.java
index a914f2f6..5efa2281 100644
--- a/owasp-java-html-sanitizer/src/test/java/org/owasp/html/HtmlPolicyBuilderTest.java
+++ b/owasp-java-html-sanitizer/src/test/java/org/owasp/html/HtmlPolicyBuilderTest.java
@@ -2000,6 +2000,152 @@ void testTextInADroppedCellOfAKeptRowIsDropped() {
table));
}
+ /**
+ * Whether an element may hold text is said of the name the author wrote,
+ * which is the name {@code allowElements(ElementPolicy, String...)} takes,
+ * so a rename to a name the policy does not allow in its own right keeps
+ * the text (#445). The gate used to follow the emitted name, so the span
+ * survived as an empty div.
+ */
+ @Test
+ void testTextSurvivesARenameToAnUnallowedName() {
+ assertEquals(
+ "
hi
",
+ apply(
+ new HtmlPolicyBuilder()
+ .allowElements((name, attrs) -> "div", "span")
+ .allowWithoutAttributes("span"),
+ "hi"));
+ assertEquals(
+ "hi
xyz
",
+ apply(
+ new HtmlPolicyBuilder()
+ .allowElements((name, attrs) -> "div", "b")
+ .allowElements("i"),
+ "hi xyz"));
+ }
+
+ /**
+ * {@code allowTextIn} takes the name the author wrote too, so it reaches a
+ * renamed element, and without it the text of a raw-text element written
+ * under that name stays out, whatever the policy renames it to.
+ */
+ @Test
+ void testAllowTextInFollowsTheInputNameOfARenamedElement() {
+ HtmlPolicyBuilder styleToDiv = new HtmlPolicyBuilder()
+ .allowElements((name, attrs) -> "div", "style");
+ assertEquals(
+ "", apply(styleToDiv, ""));
+ assertEquals(
+ "a<b
",
+ apply(styleToDiv.allowTextIn("style"), ""));
+ }
+
+ /**
+ * A rename into an element whose content a browser reads literally is held
+ * to the bar the builder sets for that name: text in a {@code style} needs
+ * {@code allowTextIn("style")} whether the author wrote {@code ", apply(divToStyle, "a{b:c}
"));
+ assertEquals(
+ "",
+ apply(divToStyle.allowTextIn("style"), "a{b:c}
"));
+ }
+
+ /**
+ * A void element renamed to one that is not void is closed at once (#450).
+ * The lexer never produces a close tag for {@code br}, and the balancer,
+ * which also goes by the input name, never synthesizes one, so the renamed
+ * element used to stay open until its parent closed and swallowed every
+ * sibling after it. Same result whether or not the target may hold text:
+ * the text after the void element belongs to the paragraph.
+ */
+ @Test
+ void testVoidElementRenamedToANonVoidOneIsClosedAtOnce() {
+ String html = "a
bcd
e
";
+ assertEquals(
+ "abcd
e
",
+ apply(
+ new HtmlPolicyBuilder()
+ .allowElements((name, attrs) -> "span", "br")
+ .allowElements("p", "span"),
+ html));
+ assertEquals(
+ "abcd
e
",
+ apply(
+ new HtmlPolicyBuilder()
+ .allowElements((name, attrs) -> "span", "br")
+ .allowElements("p"),
+ html));
+ }
+
+ /**
+ * The balancer caps how deep the output nests, but it never counts a void
+ * input element, so the phantom entries of #450 nested past the cap: three
+ * hundred {@code
} renamed to {@code span} came out as three hundred
+ * spans inside one another.
+ */
+ @Test
+ void testVoidElementRenamedToANonVoidOneDoesNotNest() {
+ StringBuilder html = new StringBuilder();
+ StringBuilder expected = new StringBuilder();
+ for (int i = 0; i < 300; ++i) {
+ html.append("
");
+ expected.append("");
+ }
+ assertEquals(
+ expected.toString(),
+ apply(
+ new HtmlPolicyBuilder()
+ .allowElements((name, attrs) -> "span", "br")
+ .allowElements("span"),
+ html.toString()));
+ }
+
+ /**
+ * The reverse rename, to a void element, has nothing to close in the
+ * output, and the text after the void element belongs to the enclosing
+ * container, as it does after a dropped element. Text was disallowed in
+ * the element by the name the author wrote, so that still holds.
+ */
+ @Test
+ void testNonVoidElementRenamedToAVoidOne() {
+ HtmlPolicyBuilder spanToBr = new HtmlPolicyBuilder()
+ .allowElements((name, attrs) -> "br", "span")
+ .allowWithoutAttributes("span")
+ .allowElements("p");
+ assertEquals(
+ "x
yz
", apply(spanToBr, "xyz
"));
+ assertEquals(
+ "x
z
",
+ apply(spanToBr.disallowTextIn("span"), "xyz
"));
+ }
+
+ /**
+ * The close tag of an element renamed to a void one pops that element and
+ * nothing else. Before #450 the policy pushed nothing for it, so the close
+ * tag found the nearest open element of the same input name instead and
+ * ended the outer span early, leaving {@code c} outside it.
+ */
+ @Test
+ void testCloseTagOfAnElementRenamedToAVoidOnePopsOnlyThatElement() {
+ HtmlPolicyBuilder classedSpanToBr = new HtmlPolicyBuilder()
+ .allowElements(
+ (name, attrs) -> attrs.isEmpty() ? "span" : "br", "span")
+ .allowWithoutAttributes("span")
+ .allowAttributes("class").onElements("span");
+ assertEquals(
+ "a
bc",
+ apply(classedSpanToBr, "abc"));
+ }
+
/**
* A factory is typically parked in a static final for the life of the JVM,
* so nothing it holds may point back at the throwaway builder. The value