This is an automated email from the ASF dual-hosted git repository.

cziegeler 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 f73c46b  SLING-13313 : Improve javascript scheme detection in 
XSSAPI.getValidHref()
f73c46b is described below

commit f73c46bbd1cae73db13d8e4581d9f1b8d9300b9f
Author: Carsten Ziegeler <[email protected]>
AuthorDate: Wed Aug 19 08:31:50 2026 +0200

    SLING-13313 : Improve javascript scheme detection in XSSAPI.getValidHref()
---
 .../org/apache/sling/xss/impl/XSSFilterImpl.java   | 126 ++++++++++++++++++++-
 .../org/apache/sling/xss/impl/XSSAPIImplTest.java  |  15 +++
 .../apache/sling/xss/impl/XSSFilterImplTest.java   |   9 ++
 3 files changed, 146 insertions(+), 4 deletions(-)

diff --git a/src/main/java/org/apache/sling/xss/impl/XSSFilterImpl.java 
b/src/main/java/org/apache/sling/xss/impl/XSSFilterImpl.java
index f3573d6..a3d5d9c 100644
--- a/src/main/java/org/apache/sling/xss/impl/XSSFilterImpl.java
+++ b/src/main/java/org/apache/sling/xss/impl/XSSFilterImpl.java
@@ -119,6 +119,7 @@ public class XSSFilterImpl implements XSSFilter {
             "(?:(?:" + UNRESERVED_CHARACTERS + ")|(?:" + PCT_ENCODED + ")|(?:" 
+ SUB_DELIMS + "))*";
     public static final String AUTHORITY = "(?:" + USER_INFO + "@)?" + HOST + 
"(?::" + PORT + ")?";
     public static final String SCHEME_PATTERN = 
"(?!\\s*javascript)\\p{L}[\\p{L}\\p{N}+.\\-]*";
+    private static final String JAVASCRIPT_SCHEME = "javascript:";
     public static final String FRAGMENT = "(?:" + PCHAR + "|/|\\?)*";
     public static final String QUERY = "(?:" + PCHAR + "|/|\\?)*";
     public static final String SEGMENT_NZ = "(?:" + PCHAR + ")+";
@@ -195,8 +196,6 @@ public class XSSFilterImpl implements XSSFilter {
     @Reference
     private XSSStatusService statusService;
 
-    private static final String COUNTER_INVALID_HREFS = "xss.invalid_hrefs";
-
     @Override
     public boolean check(final ProtectionContext context, final String src) {
         final XSSFilterRule ctx = this.getFilterRule(context);
@@ -227,6 +226,10 @@ public class XSSFilterImpl implements XSSFilter {
         }
         try {
             String decodedURL = URLDecoder.decode(url, 
StandardCharsets.UTF_8.name());
+            if (hasJavaScriptSchemeAfterBrowserParsing(decodedURL)) {
+                reportInvalidUrl(url);
+                return false;
+            }
             String unicodeUnescapedUrl = 
UNICODE_UNESCAPER.translate(decodedURL);
             String urlToValidate;
             if (unicodeUnescapedUrl.equals(decodedURL)) {
@@ -254,6 +257,117 @@ public class XSSFilterImpl implements XSSFilter {
         });
     }
 
+    private static boolean hasJavaScriptSchemeAfterBrowserParsing(@NotNull 
String url) {
+        String canonicalUrl = canonicalizeForBrowserSchemeParsing(url);
+        return StringUtils.startsWithIgnoreCase(canonicalUrl, 
JAVASCRIPT_SCHEME);
+    }
+
+    private static String canonicalizeForBrowserSchemeParsing(@NotNull String 
url) {
+        String htmlAttributeValue = decodeHtmlCharacterReferencesOnce(url);
+        String strippedUrl = 
stripLeadingAndTrailingC0ControlOrSpace(htmlAttributeValue);
+        return removeAsciiTabOrNewline(strippedUrl);
+    }
+
+    private static String decodeHtmlCharacterReferencesOnce(@NotNull String 
value) {
+        StringBuilder result = new StringBuilder(value.length());
+        for (int index = 0; index < value.length(); index++) {
+            char character = value.charAt(index);
+            if (character == '&') {
+                int nextIndex = decodeHtmlCharacterReferenceAt(value, index, 
result);
+                if (nextIndex > index) {
+                    index = nextIndex - 1;
+                    continue;
+                }
+            }
+            result.append(character);
+        }
+        return result.toString();
+    }
+
+    private static int decodeHtmlCharacterReferenceAt(@NotNull String value, 
int start, @NotNull StringBuilder result) {
+        if (value.startsWith("&Tab;", start)) {
+            result.append('\t');
+            return start + 5;
+        }
+        if (value.startsWith("&NewLine;", start)) {
+            result.append('\n');
+            return start + 9;
+        }
+        if (value.startsWith("&colon;", start)) {
+            result.append(':');
+            return start + 7;
+        }
+        if (start + 2 < value.length() && value.charAt(start + 1) == '#') {
+            return decodeNumericCharacterReferenceAt(value, start, result);
+        }
+        return -1;
+    }
+
+    private static int decodeNumericCharacterReferenceAt(
+            @NotNull String value, int start, @NotNull StringBuilder result) {
+        int index = start + 2;
+        int radix = 10;
+        if (index < value.length() && (value.charAt(index) == 'x' || 
value.charAt(index) == 'X')) {
+            radix = 16;
+            index++;
+        }
+
+        int digitsStart = index;
+        while (index < value.length() && Character.digit(value.charAt(index), 
radix) != -1) {
+            index++;
+        }
+        if (index == digitsStart) {
+            return -1;
+        }
+
+        int codePoint;
+        try {
+            codePoint = Integer.parseUnsignedInt(value.substring(digitsStart, 
index), radix);
+        } catch (NumberFormatException e) {
+            return -1;
+        }
+        if (!Character.isValidCodePoint(codePoint)) {
+            return -1;
+        }
+
+        if (index < value.length() && value.charAt(index) == ';') {
+            index++;
+        }
+        result.appendCodePoint(codePoint);
+        return index;
+    }
+
+    private static String stripLeadingAndTrailingC0ControlOrSpace(@NotNull 
String value) {
+        int start = 0;
+        int end = value.length();
+        while (start < end && isC0ControlOrSpace(value.charAt(start))) {
+            start++;
+        }
+        while (end > start && isC0ControlOrSpace(value.charAt(end - 1))) {
+            end--;
+        }
+        return value.substring(start, end);
+    }
+
+    private static String removeAsciiTabOrNewline(@NotNull String value) {
+        StringBuilder result = new StringBuilder(value.length());
+        for (int index = 0; index < value.length(); index++) {
+            char character = value.charAt(index);
+            if (!isAsciiTabOrNewline(character)) {
+                result.append(character);
+            }
+        }
+        return result.toString();
+    }
+
+    private static boolean isC0ControlOrSpace(char character) {
+        return character <= ' ';
+    }
+
+    private static boolean isAsciiTabOrNewline(char character) {
+        return character == '\t' || character == '\n' || character == '\r';
+    }
+
     private boolean runHrefValidation(@NotNull String url) {
         // Same logic as in 
org.owasp.validator.html.scan.MagicSAXFilter.startElement()
         String urlLowerCase = url.toLowerCase();
@@ -276,12 +390,16 @@ public class XSSFilterImpl implements XSSFilter {
             }
         }
         if (!isValid) {
-            statusService.reportInvalidUrl(url);
-            Optional.ofNullable(metricsService).ifPresent(service -> 
service.invalidHref());
+            reportInvalidUrl(url);
         }
         return isValid;
     }
 
+    private void reportInvalidUrl(@NotNull String url) {
+        statusService.reportInvalidUrl(url);
+        Optional.ofNullable(metricsService).ifPresent(service -> 
service.invalidHref());
+    }
+
     @Activate
     @Modified
     protected void activate(ComponentContext componentContext, Configuration 
configuration) {
diff --git a/src/test/java/org/apache/sling/xss/impl/XSSAPIImplTest.java 
b/src/test/java/org/apache/sling/xss/impl/XSSAPIImplTest.java
index 28a15ce..32d1215 100644
--- a/src/test/java/org/apache/sling/xss/impl/XSSAPIImplTest.java
+++ b/src/test/java/org/apache/sling/xss/impl/XSSAPIImplTest.java
@@ -435,6 +435,21 @@ public class XSSAPIImplTest {
             {"my/jcr:content/this path has spaces", 
"my/jcr:content/this%20path%20has%20spaces"},
             {"\" onClick=ugly", "%22%20onClick=ugly"},
             {"javascript:ugly", ""},
+            {"java&Tab;script:void(document.body.dataset.executed=1)", ""},
+            {"java&NewLine;script:void(document.body.dataset.executed=1)", ""},
+            {"java&#9;script:void(document.body.dataset.executed=1)", ""},
+            {"java&#x0A;script:void(document.body.dataset.executed=1)", ""},
+            {"java&Tab;script&colon;void(document.body.dataset.executed=1)", 
""},
+            {"java\rscript:void(document.body.dataset.executed=1)", ""},
+            {"\u0001javascript:void(document.body.dataset.executed=1)", ""},
+            {
+                "java&amp;Tab;script:void(document.body.dataset.executed=1)",
+                "java&amp;Tab;script:void(document.body.dataset.executed=1)"
+            },
+            {
+                "java&#38;Tab;script:void(document.body.dataset.executed=1)",
+                "java&#38;Tab;script:void(document.body.dataset.executed=1)"
+            },
             {"http://localhost:4502";, "http://localhost:4502"},
             {"http://localhost:4502/test";, "http://localhost:4502/test"},
             {"http://localhost:4502/jcr:content/test";, 
"http://localhost:4502/jcr:content/test"},
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 851d654..12c7513 100644
--- a/src/test/java/org/apache/sling/xss/impl/XSSFilterImplTest.java
+++ b/src/test/java/org/apache/sling/xss/impl/XSSFilterImplTest.java
@@ -55,6 +55,15 @@ public class XSSFilterImplTest {
     static List<Object[]> dataForValidHref() {
         List<Object[]> testData = new ArrayList<>();
         testData.add(new Object[] {"javascript:alert(1)", false});
+        testData.add(new Object[] 
{"java&Tab;script:void(document.body.dataset.executed=1)", false});
+        testData.add(new Object[] 
{"java&NewLine;script:void(document.body.dataset.executed=1)", false});
+        testData.add(new Object[] 
{"java&#9;script:void(document.body.dataset.executed=1)", false});
+        testData.add(new Object[] 
{"java&#x0A;script:void(document.body.dataset.executed=1)", false});
+        testData.add(new Object[] 
{"java&Tab;script&colon;void(document.body.dataset.executed=1)", false});
+        testData.add(new Object[] 
{"java\rscript:void(document.body.dataset.executed=1)", false});
+        testData.add(new Object[] 
{"\u0001javascript:void(document.body.dataset.executed=1)", false});
+        testData.add(new Object[] 
{"java&amp;Tab;script:void(document.body.dataset.executed=1)", true});
+        testData.add(new Object[] 
{"java&#38;Tab;script:void(document.body.dataset.executed=1)", true});
         testData.add(new Object[] {"", true});
         testData.add(new Object[] {
             
"%26%23x6a%3b%26%23x61%3b%26%23x76%3b%26%23x61%3b%26%23x73%3b%26%23x63%3b%26%23x72%3b%26%23x69%3b%26%23x70%3b%26%23x74%3b%26%23x3a%3balert%281%29",

Reply via email to