OWASP Java HTML Sanitizer is vulnerable to XSS via noscript tag and improper style tag sanitization
Description
OWASP Java HTML Sanitizer is a configureable HTML Sanitizer written in Java, allowing inclusion of HTML authored by third-parties in web applications while protecting against XSS. In version 20240325.1, OWASP java html sanitizer is vulnerable to XSS if HtmlPolicyBuilder allows noscript and style tags with allowTextIn inside the style tag. This could lead to XSS if the payload is crafted in such a way that it does not sanitise the CSS and allows tags which is not mentioned in HTML policy. At time of publication no known patch is available.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
CVE-2025-66021: OWASP Java HTML Sanitizer 20240325.1 XSS via noscript and style tags with allowTextIn, no patch available.
Vulnerability
Description
CVE-2025-66021 affects the OWASP Java HTML Sanitizer, version 20240325.1. The sanitizer is vulnerable to Cross-Site Scripting (XSS) when HtmlPolicyBuilder is configured to allow noscript and style tags with allowTextIn("style"). Due to a flaw in how the sanitizer handles text content inside CDATA elements (specifically style and script tags), HTML tags embedded within those blocks are not properly sanitized, allowing attackers to inject malicious content that bypasses the policy [1][4].
Exploitation
Details
Exploitation requires a policy that allows both noscript and style tags and enables allowTextIn for style. An attacker can craft a payload like `, which after sanitization is misinterpreted by the browser. The browser treats the content after as HTML, executing the injected script. This is an edge case where the interaction between noscript and style` tags, combined with the lexer's handling of CDATA content, leads to insufficient sanitization [4].
Impact
Successful exploitation allows an attacker to inject arbitrary JavaScript into the output HTML, potentially leading to session theft, credential harvesting, and other client-side attacks. The vulnerability bypasses the configured allowlist, meaning that even if script tags are not explicitly allowed, they can still be injected [1][4].
Mitigation
At the time of publication, no official patch is available. A fix has been proposed in commit d6e0463, which implements filtering of disallowed tags inside CDATA elements [3]. Users are advised to either apply this commit, avoid using allowTextIn with style tags when noscript is allowed, or use prepackaged policies that do not expose this functionality until an official release is made [2][4].
AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizerMaven | >= 20240325.1, < 20260101.1 | 20260101.1 |
Affected products
2- Range: = 20240325.1
- OWASP/java-html-sanitizerv5Range: = 20240325.1
Patches
34149cf02ba84Fix #363: CVE-2025-66021
2 files changed · +41 −12
owasp-java-html-sanitizer/src/main/java/org/owasp/html/ElementAndAttributePolicyBasedSanitizerPolicy.java+19 −12 modified@@ -162,26 +162,32 @@ private String stripDisallowedTags(String text) { // Only process if this looks like a valid HTML element tag // Valid tags start with a letter or / followed by a letter // Skip things like <, </>, <3, etc. + // Also handle tags with leading whitespace like < script> boolean isValidTag = false; String tagName = null; - if (tagContent.startsWith("/")) { + // Trim leading whitespace for tag name detection + String trimmedTagContent = tagContent.trim(); + + if (trimmedTagContent.startsWith("/")) { // Closing tag - must have / followed by a letter - if (tagContent.length() > 1) { - char firstChar = tagContent.charAt(1); + if (trimmedTagContent.length() > 1) { + char firstChar = trimmedTagContent.charAt(1); if (Character.isLetter(firstChar)) { isValidTag = true; - tagName = tagContent.substring(1).trim().split("\\s")[0]; + tagName = trimmedTagContent.substring(1).trim().split("\\s")[0]; tagName = HtmlLexer.canonicalElementName(tagName); } } } else { - // Opening tag - must start with a letter - char firstChar = tagContent.charAt(0); - if (Character.isLetter(firstChar)) { - isValidTag = true; - tagName = tagContent.trim().split("\\s")[0]; - tagName = HtmlLexer.canonicalElementName(tagName); + // Opening tag - must start with a letter (after trimming whitespace) + if (trimmedTagContent.length() > 0) { + char firstChar = trimmedTagContent.charAt(0); + if (Character.isLetter(firstChar)) { + isValidTag = true; + tagName = trimmedTagContent.split("\\s")[0]; + tagName = HtmlLexer.canonicalElementName(tagName); + } } } @@ -224,8 +230,9 @@ private String stripDisallowedTags(String text) { break; } String nextTagContent = text.substring(nextTagStart + 1, nextTagEnd); - String nextTagName = nextTagContent.trim().split("\\s")[0]; - if (nextTagContent.startsWith("/")) { + String trimmedNextTagContent = nextTagContent.trim(); + String nextTagName = trimmedNextTagContent.split("\\s")[0]; + if (trimmedNextTagContent.startsWith("/")) { // Closing tag nextTagName = nextTagName.substring(1); nextTagName = HtmlLexer.canonicalElementName(nextTagName);
owasp-java-html-sanitizer/src/test/java/org/owasp/html/HtmlSanitizerTest.java+22 −0 modified@@ -584,6 +584,28 @@ public static final void testCVE202566021_5() { assertEquals(expectedPayload, sanitized); } + /** + * Test that <script> tags with space < script> are sanitized correctly. + */ + @Test + public static final void testCVE202566021_6() { + // Arrange: Attempt to inject a <script> inside <style>. Only 'style' and 'noscript' elements are allowed. + String actualPayload = "<noscript><style>/* user content */.x { font-size: 12px; }< script>alert('XSS Attack!')</script></style></noscript>"; + String expectedPayload = "<noscript><style>/* user content */.x { font-size: 12px; }</style></noscript>"; + + HtmlPolicyBuilder htmlPolicyBuilder = new HtmlPolicyBuilder(); + PolicyFactory policy = htmlPolicyBuilder + .allowElements("style", "noscript") + .allowTextIn("style") + .toFactory(); + + // Act + String sanitized = policy.sanitize(actualPayload); + + // Assert + assertEquals(expectedPayload, sanitized); + } + private static String sanitize(@Nullable String html) { StringBuilder sb = new StringBuilder(); HtmlStreamRenderer renderer = HtmlStreamRenderer.create(
b98cdf1cd5e1Fix #363: CVE-2025-66021
1 file changed · +40 −5
owasp-java-html-sanitizer/src/test/java/org/owasp/html/HtmlSanitizerTest.java+40 −5 modified@@ -454,9 +454,24 @@ public static final void testStylingCornerCase() { assertEquals(want, sanitize(input)); } + /** + * These 5 tests cover regression scenarios for CVE-2025-66021, which relates to + * improper sanitization of HTML content involving <style> and <noscript> tags. + * The tests ensure that HTMLSanitizer: + * - properly closes any opened elements, + * - only allows allowed elements inside <style> blocks, + * - prevents injection of forbidden HTML or scripts within style or noscript, + * - does not allow unexpected element escape or context breaking. + */ + + /** + * Test #1: + * Verify that unallowed elements (<div>) injected inside <style> are removed, + * and only allowed content (CSS and allowed elements) remain. + */ @Test public static final void testCVE202566021_1() { - // Arrange + // Arrange: Attempt to inject a <div> inside <style>. Only 'style' and 'noscript' are allowed. String actualPayload = "<noscript><style>/* user content */.x { font-size: 12px; }<div id=\"evil\">XSS?</div></style></noscript>"; String expectedPayload = "<noscript><style>/* user content */.x { font-size: 12px; }</style></noscript>"; @@ -473,9 +488,14 @@ public static final void testCVE202566021_1() { assertEquals(expectedPayload, sanitized); } + /** + * Test #2: + * Ensure that <script> tags (attempting script injection) are stripped out + * even when they appear inside allowed <style> tags. + */ @Test public static final void testCVE202566021_2() { - // Arrange + // Arrange: Attempt to inject a <script> inside <style>. Only 'style' and 'noscript' are allowed. String actualPayload = "<noscript><style>/* user content */.x { font-size: 12px; }<script>alert('XSS Attack!')</script></style></noscript>"; String expectedPayload = "<noscript><style>/* user content */.x { font-size: 12px; }</style></noscript>"; @@ -492,9 +512,14 @@ public static final void testCVE202566021_2() { assertEquals(expectedPayload, sanitized); } + /** + * Test #3: + * Ensure that, if <div> is allowed, then <div> injected inside <style> + * is retained by the sanitizer (since it is now in the policy). + */ @Test public static final void testCVE202566021_3() { - // Arrange + // Arrange: <div> is now allowed, so it should survive sanitization inside <style>. String actualPayload = "<noscript><style>/* user content */.x { font-size: 12px; }<div id=\"good\">ALLOWED?</div></style></noscript>"; String expectedPayload = "<noscript><style>/* user content */.x { font-size: 12px; }<div id=\"good\">ALLOWED?</div></style></noscript>"; @@ -511,9 +536,14 @@ public static final void testCVE202566021_3() { assertEquals(expectedPayload, sanitized); } + /** + * Test #4: + * Confirm that an attempt to prematurely close <style> with </noscript>, then inject a script, + * does not allow the injected script. Sanitizer closes elements properly and only emits allowed tags. + */ @Test public static final void testCVE202566021_4() { - // Arrange + // Arrange: Try to break out of <style> and <noscript>, then add a script. Only style/noscript/p allowed. String actualPayload = "<noscript><style></noscript><script>alert(1)</script>"; String expectedPayload = "<noscript><style></noscript></style></noscript>"; @@ -530,9 +560,14 @@ public static final void testCVE202566021_4() { assertEquals(expectedPayload, sanitized); } + /** + * Test #5: + * Like Test #4, but with <p> instead of <noscript>. Ensures sanitizer emits correctly closed tags + * and strips the injected script tag completely. + */ @Test public static final void testCVE202566021_5() { - // Arrange + // Arrange: Try to break out of <style> through <p>, then add a script. Only style/noscript/p allowed. String actualPayload = "<p><style></p><script>alert(1)</script>"; String expectedPayload = "<p><style></p></style></p>";
d6e0463ed3b4Fix #363: CVE-2025-66021
4 files changed · +262 −10
owasp-java-html-sanitizer/src/main/java/org/owasp/html/ElementAndAttributePolicyBasedSanitizerPolicy.java+156 −1 modified@@ -94,8 +94,163 @@ public void closeDocument() { public void text(String textChunk) { if (!skipText) { - out.text(textChunk); + // Check if we're inside a CDATA element (style/script) with allowTextIn + // where tags are reclassified as UNESCAPED text and need to be validated + // Note: Only style and script are CDATA elements; noscript/noembed/noframes are PCDATA + boolean insideCdataElement = false; + for (int i = openElementStack.size() - 1; i >= 0; i -= 2) { + String adjustedName = openElementStack.get(i); + if (adjustedName != null + && allowedTextContainers.contains(adjustedName) + && ("style".equals(adjustedName) || "script".equals(adjustedName))) { + insideCdataElement = true; + break; + } + } + + // If inside a CDATA element (style/script) with allowTextIn, we need to filter out + // HTML tags that aren't allowed because tags inside these blocks are reclassified + // as UNESCAPED text by the lexer + if (insideCdataElement && textChunk != null && textChunk.indexOf('<') >= 0) { + // Strip out HTML tags that aren't in the allowed elements list + String filtered = stripDisallowedTags(textChunk); + out.text(filtered); + } else { + out.text(textChunk); + } + } + } + + /** + * Strips out HTML tags that aren't in the allowed elements list from text content. + * This is used when tags appear inside text containers (like style blocks) where + * they're treated as text but should still be validated. + */ + private String stripDisallowedTags(String text) { + if (text == null) { + return text; + } + + StringBuilder result = new StringBuilder(); + int len = text.length(); + int i = 0; + + while (i < len) { + int tagStart = text.indexOf('<', i); + if (tagStart < 0) { + // No more tags, append the rest + result.append(text.substring(i)); + break; + } + + // Append text before the tag + if (tagStart > i) { + result.append(text.substring(i, tagStart)); + } + + // Find the end of the tag (either '>' or end of string) + int tagEnd = text.indexOf('>', tagStart + 1); + if (tagEnd < 0) { + // Unclosed tag, skip it + i = tagStart + 1; + continue; + } + + // Extract the tag content (between < and >) + String tagContent = text.substring(tagStart + 1, tagEnd); + + // Only process if this looks like a valid HTML element tag + // Valid tags start with a letter or / followed by a letter + // Skip things like <, </>, <3, etc. + boolean isValidTag = false; + String tagName = null; + + if (tagContent.startsWith("/")) { + // Closing tag - must have / followed by a letter + if (tagContent.length() > 1) { + char firstChar = tagContent.charAt(1); + if (Character.isLetter(firstChar)) { + isValidTag = true; + tagName = tagContent.substring(1).trim().split("\\s")[0]; + tagName = HtmlLexer.canonicalElementName(tagName); + } + } + } else { + // Opening tag - must start with a letter + char firstChar = tagContent.charAt(0); + if (Character.isLetter(firstChar)) { + isValidTag = true; + tagName = tagContent.trim().split("\\s")[0]; + tagName = HtmlLexer.canonicalElementName(tagName); + } + } + + if (!isValidTag) { + // Not a valid HTML tag, just append it as-is + result.append('<').append(tagContent).append('>'); + i = tagEnd + 1; + continue; + } + + // Check if it's a closing tag + if (tagContent.startsWith("/")) { + // Only allow closing tags if the element is allowed + if (elAndAttrPolicies.containsKey(tagName)) { + result.append('<').append(tagContent).append('>'); + } + // Otherwise skip the closing tag + i = tagEnd + 1; + } else { + // Opening tag - only allow tags if the element is in the allowed list + if (elAndAttrPolicies.containsKey(tagName)) { + result.append('<').append(tagContent).append('>'); + i = tagEnd + 1; + } else { + // Skip disallowed tag and its content until matching closing tag + i = tagEnd + 1; + // Track nesting level to find the matching closing tag + int nestingLevel = 1; + while (i < len && nestingLevel > 0) { + int nextTagStart = text.indexOf('<', i); + if (nextTagStart < 0) { + // No more tags, skip to end + i = len; + break; + } + int nextTagEnd = text.indexOf('>', nextTagStart + 1); + if (nextTagEnd < 0) { + // Unclosed tag, skip to end + i = len; + break; + } + String nextTagContent = text.substring(nextTagStart + 1, nextTagEnd); + String nextTagName = nextTagContent.trim().split("\\s")[0]; + if (nextTagContent.startsWith("/")) { + // Closing tag + nextTagName = nextTagName.substring(1); + nextTagName = HtmlLexer.canonicalElementName(nextTagName); + if (nextTagName.equals(tagName)) { + nestingLevel--; + if (nestingLevel == 0) { + // Found matching closing tag, skip it and continue + i = nextTagEnd + 1; + break; + } + } + } else { + // Opening tag + nextTagName = HtmlLexer.canonicalElementName(nextTagName); + if (nextTagName.equals(tagName)) { + nestingLevel++; + } + } + i = nextTagEnd + 1; + } + } + } } + + return result.toString(); } public void openTag(String elementName, List<String> attrs) {
owasp-java-html-sanitizer/src/test/java/org/owasp/html/HtmlLexerTest.java+6 −1 modified@@ -46,14 +46,19 @@ public class HtmlLexerTest extends TestCase { public final void testHtmlLexer() throws Exception { // Do the lexing. String input = new String(Files.readAllBytes(Paths.get(getClass().getResource("htmllexerinput1.html").toURI())), StandardCharsets.UTF_8); + // Normalize line endings in input to handle Windows/Unix differences + input = input.replace("\r\n", "\n").replace("\r", "\n"); StringBuilder actual = new StringBuilder(); lex(input, actual); // Get the golden. String golden = new String(Files.readAllBytes(Paths.get(getClass().getResource("htmllexergolden1.txt").toURI())), StandardCharsets.UTF_8); + // Normalize line endings to handle Windows/Unix differences + golden = golden.replace("\r\n", "\n").replace("\r", "\n"); + String actualStr = actual.toString().replace("\r\n", "\n").replace("\r", "\n"); // Compare. - assertEquals(golden, actual.toString()); + assertEquals(golden, actualStr); } @Test
owasp-java-html-sanitizer/src/test/java/org/owasp/html/HtmlSanitizerFuzzerTest.java+5 −8 modified@@ -28,16 +28,13 @@ package org.owasp.html; -import java.io.BufferedReader; -import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; import java.util.List; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; - -import org.apache.commons.codec.Resources; /** * Throws malformed inputs at the HTML sanitizer to try and crash it. @@ -62,9 +59,9 @@ public void text(String textChunk) { /* do nothing */ } }; public final void testFuzzHtmlParser() throws Exception { - String html = new BufferedReader(new InputStreamReader( - Resources.getInputStream("benchmark-data/Yahoo!.html"), - StandardCharsets.UTF_8)).lines().collect(Collectors.joining()); + String html = new String(Files.readAllBytes( + Paths.get(getClass().getResource("/benchmark-data/Yahoo!.html").toURI())), + StandardCharsets.UTF_8); int length = html.length(); char[] fuzzyHtml0 = new char[length];
owasp-java-html-sanitizer/src/test/java/org/owasp/html/HtmlSanitizerTest.java+95 −0 modified@@ -454,6 +454,101 @@ public static final void testStylingCornerCase() { assertEquals(want, sanitize(input)); } + @Test + public static final void testCVE202566021_1() { + // Arrange + String actualPayload = "<noscript><style>/* user content */.x { font-size: 12px; }<div id=\"evil\">XSS?</div></style></noscript>"; + String expectedPayload = "<noscript><style>/* user content */.x { font-size: 12px; }</style></noscript>"; + + HtmlPolicyBuilder htmlPolicyBuilder = new HtmlPolicyBuilder(); + PolicyFactory policy = htmlPolicyBuilder + .allowElements("style", "noscript") + .allowTextIn("style") + .toFactory(); + + // Act + String sanitized = policy.sanitize(actualPayload); + + // Assert + assertEquals(expectedPayload, sanitized); + } + + @Test + public static final void testCVE202566021_2() { + // Arrange + String actualPayload = "<noscript><style>/* user content */.x { font-size: 12px; }<script>alert('XSS Attack!')</script></style></noscript>"; + String expectedPayload = "<noscript><style>/* user content */.x { font-size: 12px; }</style></noscript>"; + + HtmlPolicyBuilder htmlPolicyBuilder = new HtmlPolicyBuilder(); + PolicyFactory policy = htmlPolicyBuilder + .allowElements("style", "noscript") + .allowTextIn("style") + .toFactory(); + + // Act + String sanitized = policy.sanitize(actualPayload); + + // Assert + assertEquals(expectedPayload, sanitized); + } + + @Test + public static final void testCVE202566021_3() { + // Arrange + String actualPayload = "<noscript><style>/* user content */.x { font-size: 12px; }<div id=\"good\">ALLOWED?</div></style></noscript>"; + String expectedPayload = "<noscript><style>/* user content */.x { font-size: 12px; }<div id=\"good\">ALLOWED?</div></style></noscript>"; + + HtmlPolicyBuilder htmlPolicyBuilder = new HtmlPolicyBuilder(); + PolicyFactory policy = htmlPolicyBuilder + .allowElements("style", "noscript", "div") + .allowTextIn("style") + .toFactory(); + + // Act + String sanitized = policy.sanitize(actualPayload); + + // Assert + assertEquals(expectedPayload, sanitized); + } + + @Test + public static final void testCVE202566021_4() { + // Arrange + String actualPayload = "<noscript><style></noscript><script>alert(1)</script>"; + String expectedPayload = "<noscript><style></noscript></style></noscript>"; + + HtmlPolicyBuilder htmlPolicyBuilder = new HtmlPolicyBuilder(); + PolicyFactory policy = htmlPolicyBuilder + .allowElements("style", "noscript", "p") + .allowTextIn("style") + .toFactory(); + + // Act + String sanitized = policy.sanitize(actualPayload); + + // Assert + assertEquals(expectedPayload, sanitized); + } + + @Test + public static final void testCVE202566021_5() { + // Arrange + String actualPayload = "<p><style></p><script>alert(1)</script>"; + String expectedPayload = "<p><style></p></style></p>"; + + HtmlPolicyBuilder htmlPolicyBuilder = new HtmlPolicyBuilder(); + PolicyFactory policy = htmlPolicyBuilder + .allowElements("style", "noscript", "p") + .allowTextIn("style") + .toFactory(); + + // Act + String sanitized = policy.sanitize(actualPayload); + + // Assert + assertEquals(expectedPayload, sanitized); + } + private static String sanitize(@Nullable String html) { StringBuilder sb = new StringBuilder(); HtmlStreamRenderer renderer = HtmlStreamRenderer.create(
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-g9gq-3pfx-2gw2ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-66021ghsaADVISORY
- github.com/OWASP/java-html-sanitizer/commit/4149cf02ba84db13e8e9d7ee1b01b3f47238e072ghsaWEB
- github.com/OWASP/java-html-sanitizer/commit/b98cdf1cd5e156a6259b01aa8cdc7372c6efde1eghsaWEB
- github.com/OWASP/java-html-sanitizer/commit/d6e0463ed3b48777ecd187913ffdbe767508ff45ghsaWEB
- github.com/OWASP/java-html-sanitizer/issues/363ghsaWEB
- github.com/OWASP/java-html-sanitizer/security/advisories/GHSA-g9gq-3pfx-2gw2ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.