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 8232e99  SLING-13334 fix input validation gaps
8232e99 is described below

commit 8232e9956808fccf7007f58936ca6588fe200506
Author: Joerg Hoh <[email protected]>
AuthorDate: Mon Sep 14 17:33:56 2026 +0200

    SLING-13334 fix input validation gaps
---
 src/main/java/org/apache/sling/xss/XSSAPI.java     |  10 +-
 .../java/org/apache/sling/xss/impl/XSSAPIImpl.java |  88 +++++++++++--
 .../org/apache/sling/xss/impl/XSSFilterImpl.java   | 144 +++++++++++++++++++--
 .../org/apache/sling/xss/impl/XSSAPIImplTest.java  | 140 +++++++++++++++++++-
 .../apache/sling/xss/impl/XSSFilterImplTest.java   |  75 +++++++++++
 5 files changed, 429 insertions(+), 28 deletions(-)

diff --git a/src/main/java/org/apache/sling/xss/XSSAPI.java 
b/src/main/java/org/apache/sling/xss/XSSAPI.java
index 8cdd309..0496e5d 100644
--- a/src/main/java/org/apache/sling/xss/XSSAPI.java
+++ b/src/main/java/org/apache/sling/xss/XSSAPI.java
@@ -129,7 +129,10 @@ public interface XSSAPI {
 
     /**
      * Validate multi-line comment to be used inside a 
&lt;script&gt;...&lt;/script&gt; or &lt;style&gt;...&lt;/style&gt; block. 
Multi-line
-     * comment end block is disallowed.
+     * comment end block is disallowed, as are character sequences that would 
close the surrounding raw-text element or
+     * change the HTML tokenizer's script-data state (case-insensitive {@code 
&lt;/script}, {@code &lt;/style},
+     * {@code &lt;!--} and {@code --&gt;}), since HTML parsing ends a script 
or style element at the first closing tag
+     * regardless of the JavaScript/CSS comment state.
      *
      * @param comment           the comment to be used
      * @param defaultComment    a default value to use if the comment is 
{@code null} or not valid.
@@ -140,6 +143,11 @@ public interface XSSAPI {
     /**
      * Validate a JSON string
      *
+     * <p>The returned string is re-serialized so that it stays inert when 
inlined into an HTML
+     * {@code <script>} element: the characters {@code <}, U+2028 and U+2029 
are emitted as JSON
+     * unicode escape sequences ({@code \u003C}, {@code \u2028}, {@code 
\u2029}),
+     * which represent the same JSON value.</p>
+     *
      * @param json          the JSON string to validate
      * @param defaultJson   the default value to use if {@code json} is {@code 
null} or not valid
      * @return a valid JSON string
diff --git a/src/main/java/org/apache/sling/xss/impl/XSSAPIImpl.java 
b/src/main/java/org/apache/sling/xss/impl/XSSAPIImpl.java
index fef2136..c469fbd 100644
--- a/src/main/java/org/apache/sling/xss/impl/XSSAPIImpl.java
+++ b/src/main/java/org/apache/sling/xss/impl/XSSAPIImpl.java
@@ -61,6 +61,17 @@ public class XSSAPIImpl implements XSSAPI {
 
     private static final Pattern PATTERN_AUTO_DIMENSION = 
Pattern.compile("['\"]?auto['\"]?");
 
+    /**
+     * Character sequences that must never appear in a multi-line comment that 
is embedded inside a
+     * {@code <script>} or {@code <style>} element (the context documented for
+     * {@link org.apache.sling.xss.XSSAPI#getValidMultiLineComment(String, 
String)}): besides the
+     * comment-end token itself, HTML raw-text parsing ends such an element at 
the first
+     * case-insensitive {@code </script} / {@code </style} regardless of the 
JavaScript/CSS comment
+     * state, and {@code <!--} / {@code -->} shift the tokenizer's script-data 
escape state.
+     */
+    private static final Pattern PATTERN_MULTI_LINE_COMMENT_BREAKOUT =
+            Pattern.compile("\\*/|</script|</style|<!--|-->", 
Pattern.CASE_INSENSITIVE);
+
     private SAXParserFactory factory;
 
     private volatile JsonReaderFactory jsonReaderFactory;
@@ -223,22 +234,48 @@ public class XSSAPIImpl implements XSSAPI {
     }
 
     private static final String NON_ASCII = "\\x00\\x08\\x0B\\x0C\\x0E-\\x1F";
-    /** http://www.w3.org/TR/css-syntax-3/#number-token-diagram */
-    private static final String NUMBER = 
"[+-]?[\\d]*[\\.]?[\\d]*(?:[e][+-]?\\d+)?";
+    /**
+     * http://www.w3.org/TR/css-syntax-3/#number-token-diagram
+     * <p>
+     * Must not be able to match the empty string and uses possessive 
quantifiers so that a
+     * backtracking regex engine can never re-split a digit run between its 
sub-expressions: this
+     * production is repeated inside {@link #FUNCTION}, and a nullable, 
ambiguous expression inside
+     * a repetition is the classic exponential-backtracking (ReDoS) shape.
+     */
+    private static final String NUMBER = 
"[+-]?+(?:\\d++(?:\\.\\d*+)?+|\\.\\d++)(?:e[+-]?+\\d++)?+";
     /** http://www.w3.org/TR/css-syntax-3/#hex-digit-diagram */
     private static final String HEX_DIGITS = "#[0-9a-f]*";
     /** http://www.w3.org/TR/css-syntax-3/#ident-token-diagram */
     private static final String IDENTIFIER = "-?[a-z_" + NON_ASCII + 
"][\\w_\\-" + NON_ASCII + "]*";
-    /** http://www.w3.org/TR/css-syntax-3/#string-token-diagram */
+    /**
+     * http://www.w3.org/TR/css-syntax-3/#string-token-diagram
+     * <p>
+     * Deliberately stricter than the CSS grammar: quote characters of either 
kind (raw or
+     * backslash-escaped), backslashes and the markup characters {@code <} and 
{@code >} are not
+     * allowed inside the string at all. A validated style token may be 
emitted into a single- or
+     * double-quoted {@code style} attribute or into a {@code <style>} 
element, and HTML parsing
+     * ignores CSS escaping: a single-quoted CSS string containing a raw 
{@code "} (e.g. the token
+     * {@code '" onmouseover="alert(1) '}) would otherwise close a 
double-quoted attribute, and
+     * {@code </style>} inside a string would end the style element. The 
javascript-scheme guard
+     * tolerates arbitrary embedded whitespace instead of at most one 
character.
+     */
     private static final String STRING =
-            
"\"(?:(?!javascript\\s?:)[^\"^\\\\^\\n]|(?:\\\\\"))*\"|'(?:(?!javascript\\s?:)[^'^\\\\^\\n]|(?:\\\\'))*'";
+            
"\"(?:(?!javascript\\s*:)[^\"'^\\\\\\n<>])*\"|'(?:(?!javascript\\s*:)[^\"'^\\\\\\n<>])*'";
     /** http://www.w3.org/TR/css-syntax-3/#dimension-token-diagram */
     private static final String DIMENSION = NUMBER + IDENTIFIER;
     /** http://www.w3.org/TR/css-syntax-3/#percentage-token-diagram */
     private static final String PERCENT = NUMBER + "%";
-    /** http://www.w3.org/TR/css-syntax-3/#function-token-diagram */
+    /**
+     * http://www.w3.org/TR/css-syntax-3/#function-token-diagram
+     * <p>
+     * Every iteration of the argument loop consumes at least one character 
and the quantifiers are
+     * possessive, so matching cost stays linear even for inputs that never 
close the parenthesis.
+     * The previous formulation nested nullable, ambiguous alternatives inside 
an unbounded
+     * repetition, which let an unterminated token like {@code "z(" + "1" * 
45} pin the CPU with
+     * exponential backtracking.
+     */
     private static final String FUNCTION =
-            IDENTIFIER + "\\((?:(?:" + NUMBER + ")|(?:" + IDENTIFIER + 
")|(?:[\\s]*)|(?:,))*\\)";
+            IDENTIFIER + "\\((?:[\\s,]*+(?:(?:" + NUMBER + ")|(?:" + 
IDENTIFIER + ")))*+[\\s,]*+\\)";
     /** http://www.w3.org/TR/css-syntax-3/#url-unquoted-diagram */
     private static final String URL_UNQUOTED = "[^\"^'^\\(^\\)^[" + NON_ASCII 
+ "]]*";
     /** http://www.w3.org/TR/css-syntax-3/#url-token-diagram */
@@ -295,7 +332,8 @@ public class XSSAPIImpl implements XSSAPI {
      */
     @Override
     public String getValidMultiLineComment(String comment, String 
defaultComment) {
-        if (comment != null && !comment.contains("*/")) {
+        if (comment != null
+                && 
!PATTERN_MULTI_LINE_COMMENT_BREAKOUT.matcher(comment).find()) {
             return comment;
         }
         return defaultComment;
@@ -323,7 +361,7 @@ public class XSSAPIImpl implements XSSAPI {
                                 .createReader(new StringReader(json))
                                 .readObject())
                         .close();
-                return output.getBuffer().toString();
+                return escapeJsonForHtmlContext(output.getBuffer().toString());
             } catch (Exception e) {
                 LOGGER.warn("Unable to get valid JSON from the input.", e);
                 LOGGER.debug("JSON input:\n{}", json);
@@ -336,7 +374,7 @@ public class XSSAPIImpl implements XSSAPI {
                                 .createReader(new StringReader(json))
                                 .readArray())
                         .close();
-                return output.getBuffer().toString();
+                return escapeJsonForHtmlContext(output.getBuffer().toString());
             } catch (Exception e) {
                 LOGGER.warn("Unable to get valid JSON from the input.", e);
                 LOGGER.debug("JSON input:\n{}", json);
@@ -345,6 +383,38 @@ public class XSSAPIImpl implements XSSAPI {
         return getValidJSON(defaultJson, "");
     }
 
+    /**
+     * Escapes the characters that can break out of an HTML {@code <script>} 
element or an inline
+     * event handler when serialized JSON is inlined during HTML composition: 
{@code <} (which would
+     * otherwise allow {@code </script>} or {@code <!--} sequences inside 
string values) and the
+     * JavaScript line terminators U+2028/U+2029. In serialized JSON these 
characters can only occur
+     * inside string values, and each replacement is a JSON escape sequence 
for the same character,
+     * so the returned string represents exactly the same JSON value.
+     *
+     * @param json a serialized JSON document
+     * @return the equivalent JSON document, safe to inline in HTML script 
contexts
+     */
+    private static String escapeJsonForHtmlContext(@NotNull String json) {
+        StringBuilder sb = new StringBuilder(json.length());
+        for (int i = 0; i < json.length(); i++) {
+            char c = json.charAt(i);
+            switch (c) {
+                case '<':
+                    sb.append("\\u003C");
+                    break;
+                case '\u2028':
+                    sb.append("\\u2028");
+                    break;
+                case '\u2029':
+                    sb.append("\\u2029");
+                    break;
+                default:
+                    sb.append(c);
+            }
+        }
+        return sb.toString();
+    }
+
     /**
      * @see org.apache.sling.xss.XSSAPI#getValidXML(String, String)
      */
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 7bafd38..9e00c2b 100644
--- a/src/main/java/org/apache/sling/xss/impl/XSSFilterImpl.java
+++ b/src/main/java/org/apache/sling/xss/impl/XSSFilterImpl.java
@@ -22,13 +22,16 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.io.UncheckedIOException;
+import java.io.Writer;
 import java.net.URLDecoder;
 import java.nio.charset.StandardCharsets;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Dictionary;
+import java.util.HashMap;
 import java.util.Hashtable;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.function.Function;
 import java.util.function.Supplier;
@@ -37,7 +40,9 @@ import java.util.regex.Pattern;
 import org.apache.commons.io.IOUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.text.StringEscapeUtils;
-import org.apache.commons.text.translate.NumericEntityUnescaper;
+import org.apache.commons.text.translate.AggregateTranslator;
+import org.apache.commons.text.translate.CharSequenceTranslator;
+import org.apache.commons.text.translate.LookupTranslator;
 import org.apache.sling.api.resource.LoginException;
 import org.apache.sling.api.resource.Resource;
 import org.apache.sling.api.resource.ResourceResolver;
@@ -170,11 +175,123 @@ public class XSSFilterImpl implements XSSFilter {
             AntiSamyActions.REMOVE_ATTRIBUTE_ON_INVALID,
             null);
 
-    /*
-     NumericEntityEscaper is deprecated starting with version 3.6 of 
commons-lang3, however the indicated replacement comes from
-     commons-text, which is not an OSGi bundle
-    */
-    private static final NumericEntityUnescaper UNICODE_UNESCAPER = new 
NumericEntityUnescaper();
+    /**
+     * Unescapes HTML character references in a single left-to-right pass, the 
way a browser does when
+     * reading an attribute value: numeric references ({@code &#dd;} / {@code 
&#xhh;}, terminating
+     * semicolon optional, e.g. {@code "&#106avascript:alert(1)"} decodes to 
{@code "javascript:alert(1)"})
+     * plus named references, both the HTML 4 set known to {@link 
StringEscapeUtils#unescapeHtml4(String)}
+     * and the HTML5-only names that resolve to ASCII characters (e.g. {@code 
&Tab;}, {@code &NewLine;},
+     * {@code &colon;}). commons-text's {@code NumericEntityUnescaper} cannot 
be used for the numeric part:
+     * it requires the semicolon by default, and its semiColonOptional mode 
mis-parses decimal references
+     * followed by a hex-letter character (it reads "&#106a" as the decimal 
number "106a" and gives up).
+     *
+     * <p>Numeric and named decoding are combined into a single {@link 
AggregateTranslator} - and applied to
+     * the URL exactly once - rather than run as two sequential passes: a 
two-pass approach would let a
+     * character produced by the first pass be mistaken for the start of a new 
reference by the second pass.
+     * For example {@code "&#38;Tab;"} is, per the HTML5 spec, decoded once to 
the literal text
+     * {@code "&Tab;"} (a harmless ampersand followed by literal text); a 
browser does not re-scan that
+     * output and would never turn it into a tab character, so this 
implementation must not either.
+     */
+    static final CharSequenceTranslator NUMERIC_ENTITY_UNESCAPER = new 
Html5NumericEntityUnescaper();
+
+    static final CharSequenceTranslator UNICODE_UNESCAPER = new 
AggregateTranslator(
+            NUMERIC_ENTITY_UNESCAPER, StringEscapeUtils.UNESCAPE_HTML4, new 
LookupTranslator(html5AsciiEntities()));
+
+    private static Map<CharSequence, CharSequence> html5AsciiEntities() {
+        Map<CharSequence, CharSequence> entities = new HashMap<>();
+        entities.put("&Tab;", "\t");
+        entities.put("&NewLine;", "\n");
+        entities.put("&excl;", "!");
+        entities.put("&num;", "#");
+        entities.put("&dollar;", "$");
+        entities.put("&percnt;", "%");
+        entities.put("&apos;", "'");
+        entities.put("&lpar;", "(");
+        entities.put("&rpar;", ")");
+        entities.put("&ast;", "*");
+        entities.put("&midast;", "*");
+        entities.put("&plus;", "+");
+        entities.put("&comma;", ",");
+        entities.put("&period;", ".");
+        entities.put("&sol;", "/");
+        entities.put("&colon;", ":");
+        entities.put("&semi;", ";");
+        entities.put("&equals;", "=");
+        entities.put("&quest;", "?");
+        entities.put("&commat;", "@");
+        entities.put("&lsqb;", "[");
+        entities.put("&lbrack;", "[");
+        entities.put("&bsol;", "\\");
+        entities.put("&rsqb;", "]");
+        entities.put("&rbrack;", "]");
+        entities.put("&Hat;", "^");
+        entities.put("&lowbar;", "_");
+        entities.put("&UnderBar;", "_");
+        entities.put("&grave;", "`");
+        entities.put("&DiacriticalGrave;", "`");
+        entities.put("&lcub;", "{");
+        entities.put("&lbrace;", "{");
+        entities.put("&verbar;", "|");
+        entities.put("&vert;", "|");
+        entities.put("&VerticalLine;", "|");
+        entities.put("&rcub;", "}");
+        entities.put("&rbrace;", "}");
+        return Collections.unmodifiableMap(entities);
+    }
+
+    /**
+     * Decodes numeric character references ({@code &#dd;} / {@code &#xhh;}) 
the way the HTML5
+     * specification requires for attribute values: the terminating semicolon 
is optional and a
+     * decimal reference ends at the first non-decimal-digit character (so 
{@code &#106avascript}
+     * decodes to {@code javascript}).
+     */
+    private static final class Html5NumericEntityUnescaper extends 
CharSequenceTranslator {
+
+        @Override
+        public int translate(CharSequence input, int index, Writer writer) 
throws IOException {
+            int seqEnd = input.length();
+            if (input.charAt(index) != '&' || index >= seqEnd - 2 || 
input.charAt(index + 1) != '#') {
+                return 0;
+            }
+            int start = index + 2;
+            boolean isHex = false;
+            char firstChar = input.charAt(start);
+            if (firstChar == 'x' || firstChar == 'X') {
+                start++;
+                isHex = true;
+                if (start == seqEnd) {
+                    return 0;
+                }
+            }
+            int end = start;
+            while (end < seqEnd && isEntityDigit(input.charAt(end), isHex)) {
+                end++;
+            }
+            if (end == start) {
+                return 0;
+            }
+            int entityValue;
+            try {
+                entityValue = Integer.parseInt(input.subSequence(start, 
end).toString(), isHex ? 16 : 10);
+            } catch (NumberFormatException nfe) {
+                // value too large to represent: decode to the replacement 
character, like browsers do
+                entityValue = 0xFFFD;
+            }
+            if (entityValue > Character.MAX_CODE_POINT) {
+                entityValue = 0xFFFD;
+            }
+            writer.write(new String(Character.toChars(entityValue)));
+            boolean semiNext = end != seqEnd && input.charAt(end) == ';';
+            return (semiNext ? end + 1 : end) - index;
+        }
+
+        private static boolean isEntityDigit(char ch, boolean isHex) {
+            if (ch >= '0' && ch <= '9') {
+                return true;
+            }
+            return isHex && (ch >= 'a' && ch <= 'f' || ch >= 'A' && ch <= 'F');
+        }
+    }
 
     // Default href configuration copied from the config.xml supplied with 
AntiSamy
     static final Attribute DEFAULT_HREF_ATTRIBUTE = new Attribute(
@@ -245,14 +362,13 @@ public class XSSFilterImpl implements XSSFilter {
                 reportInvalidUrl(url);
                 return false;
             }
-            String unicodeUnescapedUrl = 
UNICODE_UNESCAPER.translate(decodedURL);
-            String urlToValidate;
-            if (unicodeUnescapedUrl.equals(decodedURL)) {
-                urlToValidate = url;
-            } else {
-                urlToValidate = unicodeUnescapedUrl;
-            }
-            urlToValidate = StringEscapeUtils.unescapeHtml4(urlToValidate);
+            String numericUnescapedUrl = 
NUMERIC_ENTITY_UNESCAPER.translate(decodedURL);
+            // Decode numeric and named character references in a single pass 
over whichever base string is
+            // chosen below: chaining two separate translate() calls would let 
a character produced by the
+            // first pass (e.g. the '&' decoded from "&#38;") be mistaken by 
the second pass for the start of
+            // a new reference, which a browser never does (see 
UNICODE_UNESCAPER's javadoc).
+            String baseUrl = numericUnescapedUrl.equals(decodedURL) ? url : 
decodedURL;
+            String urlToValidate = UNICODE_UNESCAPER.translate(baseUrl);
             return runHrefValidation(urlToValidate);
         } catch (Exception e) {
             logger.warn("Unable to validate url.", e);
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 32d1215..b5d38e7 100644
--- a/src/test/java/org/apache/sling/xss/impl/XSSAPIImplTest.java
+++ b/src/test/java/org/apache/sling/xss/impl/XSSAPIImplTest.java
@@ -18,6 +18,7 @@
  */
 package org.apache.sling.xss.impl;
 
+import java.time.Duration;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
@@ -37,6 +38,7 @@ import org.apache.sling.xss.impl.status.XSSStatusService;
 import org.apache.sling.xss.impl.xml.Attribute;
 import org.jetbrains.annotations.NotNull;
 import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.MethodSource;
@@ -48,6 +50,7 @@ import org.osgi.framework.ServiceReference;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.fail;
 import static org.mockito.ArgumentMatchers.anyString;
@@ -228,6 +231,39 @@ public class XSSAPIImplTest {
         }
     }
 
+    @Test
+    public void testGetValidStyleTokenBacktrackingComplexity() {
+        // an unterminated function token used to trigger exponential 
backtracking in the FUNCTION
+        // production of CSS_TOKEN (~45 digits were enough to pin a CPU core 
practically forever);
+        // validation cost must stay linear in the input length
+        StringBuilder pathological = new 
StringBuilder("z(").append("1".repeat(100));
+        String token = pathological.toString();
+        assertTimeoutPreemptively(
+                Duration.ofSeconds(5), () -> assertEquals(RUBBISH, 
xssAPI.getValidStyleToken(token, RUBBISH)));
+    }
+
+    static String[] dataForStyleTokenBacktrackingComplexity() {
+        return new String[] {
+            // digit run seasoned with '.' and a trailing exponent marker: 
stresses NUMBER's internal
+            // split points between its integer, fractional and exponent parts
+            "z(" + "1".repeat(50) + "." + "1".repeat(50) + "e",
+            // identifier filler: stresses the NUMBER/IDENTIFIER alternation 
inside FUNCTION's loop
+            "z(" + "a".repeat(100),
+            // whitespace/comma filler: stresses the outer [\s,]*+ possessive 
class
+            "z(" + " ,".repeat(100)
+        };
+    }
+
+    @ParameterizedTest
+    @MethodSource("dataForStyleTokenBacktrackingComplexity")
+    public void 
testGetValidStyleTokenBacktrackingComplexityOtherFillerShapes(String token) {
+        // same linear-cost requirement as 
testGetValidStyleTokenBacktrackingComplexity, but for other
+        // filler shapes (not just a plain digit run), to guard against a 
partial regression that
+        // only hardens one alternative
+        assertTimeoutPreemptively(
+                Duration.ofSeconds(5), () -> assertEquals(RUBBISH, 
xssAPI.getValidStyleToken(token, RUBBISH)));
+    }
+
     @ParameterizedTest
     @MethodSource("dataForValidCSSColor")
     public void testGetValidCSSColor(String source, String expected) {
@@ -450,6 +486,15 @@ public class XSSAPIImplTest {
                 "java&#38;Tab;script:void(document.body.dataset.executed=1)",
                 "java&#38;Tab;script:void(document.body.dataset.executed=1)"
             },
+
+            // HTML5-only named character references and semicolon-less 
numeric references are
+            // decoded by browsers before URL parsing and must not smuggle a 
javascript: scheme
+            {"java&Tab;script:alert(1)", ""},
+            {"java&NewLine;script:alert(1)", ""},
+            {"javascript&colon;alert(1)", ""},
+            {"java&Tab;script&colon;alert(1)", ""},
+            {"&#106avascript:alert(1)", ""},
+            {"&#106;avascript:alert(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"},
@@ -659,8 +704,31 @@ public class XSSAPIImplTest {
             // valid strings
             {"'literal string'", "'literal string'"},
             {"\"literal string\"", "\"literal string\""},
-            {"'it\\'s here'", "'it\\'s here'"},
-            {"\"it\\\"s here\"", "\"it\\\"s here\""},
+
+            // strings must not contain quote characters, not even 
backslash-escaped ones: HTML
+            // parsing ignores CSS escaping, so a stray quote of the other 
kind breaks out of the
+            // style attribute the token is emitted into
+            {"'it\\'s here'", RUBBISH},
+            {"\"it\\\"s here\"", RUBBISH},
+            {"'\" onmouseover=\"alert(document.cookie) '", RUBBISH},
+            {"\"' onmouseover='alert(document.cookie) \"", RUBBISH},
+
+            // strings must not contain markup that could end a surrounding 
<style> element
+            {"'</style><script>alert(1)</script>'", RUBBISH},
+            {"\"</style><script>alert(1)</script>\"", RUBBISH},
+
+            // the javascript: guard must tolerate arbitrary whitespace
+            {"'javascript  :alert(1)'", RUBBISH},
+
+            // a backslash is excluded from the string content entirely, 
independent of whether it is
+            // adjacent to a quote character - e.g. a CSS hex escape like \22 
is no longer accepted
+            {"'\\22 '", RUBBISH},
+            {"'back\\slash'", RUBBISH},
+
+            // HTML entities inside a string are harmless and must still be 
accepted: <style> raw text
+            // is never entity-decoded, so "&quot;" stays literal text and 
cannot break out of a
+            // surrounding attribute the way a raw quote character would
+            {"'&quot; onmouseover=alert(1)'", "'&quot; onmouseover=alert(1)'"},
 
             // invalid strings
             {"\"bad string", RUBBISH},
@@ -669,6 +737,23 @@ public class XSSAPIImplTest {
 
             // valid parenthesis
             {"rgb(255, 255, 255)", "rgb(255, 255, 255)"},
+            {"translate(10px, 20px)", "translate(10px, 20px)"},
+            {"rgba(0,0,0,.5)", "rgba(0,0,0,.5)"},
+
+            // NUMBER/IDENTIFIER handoff boundaries that any rewrite must 
still accept: an
+            // exponent marker with no following digits falls back to being 
consumed as part of the
+            // identifier/unit instead of the (failed) exponent group, and a 
trailing dot with no
+            // following digit is still a valid fractional NUMBER
+            {"2e2em", "2e2em"},
+            {"2epx", "2epx"},
+            {"5e-x", "5e-x"},
+            {"5.em", "5.em"},
+
+            // degenerate tokens that the old NUMBER (which could match the 
empty string) used to
+            // accept as a bare sign or a lone dot; NUMBER must require at 
least one digit now
+            {"+", RUBBISH},
+            {"-", RUBBISH},
+            {".", RUBBISH},
 
             // invalid parenthesis
             {"rgb(255, 255, 255", RUBBISH},
@@ -678,6 +763,11 @@ public class XSSAPIImplTest {
             {"url(http://example.com/test.png)", 
"url(http://example.com/test.png)"},
             {"url('image/test.png')", "url('image/test.png')"},
 
+            // the URL production reuses STRING, so the same quote/markup 
breakout protection must
+            // apply through url(...) as well
+            {"url('</style><script>alert(1)</script>')", RUBBISH},
+            {"url('\" onmouseover=\"alert(document.cookie) ')", RUBBISH},
+
             // invalid tokens
             {"color: red", RUBBISH}
         };
@@ -713,7 +803,28 @@ public class XSSAPIImplTest {
             // Source            Expected Result
             {null, RUBBISH},
             {"blah */ hack", RUBBISH},
-            {"Valid comment", "Valid comment"}
+            // sequences that end the surrounding <script>/<style> raw-text 
element or shift the
+            // script-data tokenizer state must not survive validation
+            {"</script><script>alert(document.domain)//", RUBBISH},
+            {"blah </ScRiPt ><img src=x onerror=alert(1)>", RUBBISH},
+            {"</style><script>alert(1)</script>", RUBBISH},
+            {"blah </StYlE >", RUBBISH},
+            {"<!-- enters script-data-escaped state", RUBBISH},
+            {"leaves the escaped state -->", RUBBISH},
+            // a newline or tab before the tag-name-close is still a closing 
tag to the HTML tokenizer
+            {"blah </script\nafter", RUBBISH},
+            {"blah </style\tafter", RUBBISH},
+            {"Valid comment", "Valid comment"},
+            {"Valid /* nested comment start", "Valid /* nested comment start"},
+            // sequences that must NOT be blocked: a backslash-escaped slash 
is inert at the HTML
+            // tokenizer level (only a literal "</" ends the raw-text 
element), a bare opening tag
+            // cannot end or nest inside the raw-text element it is already 
inside of, and a
+            // near-miss that is one character short of an actual breakout 
token is not a breakout
+            {"contains <\\/script escaped slash", "contains <\\/script escaped 
slash"},
+            {"a bare <script> tag is not a closing tag", "a bare <script> tag 
is not a closing tag"},
+            {"a bare <style> tag is not a closing tag", "a bare <style> tag is 
not a closing tag"},
+            {"almost </scrip but not quite", "almost </scrip but not quite"},
+            {"almost <!- but not quite", "almost <!- but not quite"}
         };
     }
 
@@ -734,7 +845,28 @@ public class XSSAPIImplTest {
             {"[]", "[]"},
             {"[1,2]", "[1,2]"},
             {"[1", RUBBISH_JSON},
-            {"[{\"test\": \"test\"}]", "[{\"test\":\"test\"}]"}
+            {"[{\"test\": \"test\"}]", "[{\"test\":\"test\"}]"},
+            // values that could break out of an inline <script> element must 
be escaped
+            // (semantics-preserving JSON escapes)
+            {
+                "{\"a\":\"</script><script>alert(1)</script>\"}",
+                
"{\"a\":\"\\u003C/script>\\u003Cscript>alert(1)\\u003C/script>\"}"
+            },
+            {"[\"</script>\"]", "[\"\\u003C/script>\"]"},
+            {"{\"a\":\"x\u2028y\u2029z\"}", "{\"a\":\"x\\u2028y\\u2029z\"}"},
+
+            // a pre-escaped '<' in the input is decoded by the JSON parser to 
a literal '<' and must
+            // be re-escaped on the way out, not passed through as-is
+            {"{\"a\":\"\\u003Cscript\\u003E\"}", "{\"a\":\"\\u003Cscript>\"}"},
+
+            // object keys go through the same serialized output and must be 
escaped too, not just
+            // string values
+            {"{\"<script>x</script>\": \"v\"}", 
"{\"\\u003Cscript>x\\u003C/script>\":\"v\"}"},
+
+            // '<!--' is neutralized once its '<' is escaped; a lone '-->' 
with no preceding '<!--'
+            // contains no '<' and is left untouched
+            {"{\"a\":\"<!-- comment\"}", "{\"a\":\"\\u003C!-- comment\"}"},
+            {"[\"-->\",\"safe\"]", "[\"-->\",\"safe\"]"}
         };
     }
 
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 1a18b5d..01abd8d 100644
--- a/src/test/java/org/apache/sling/xss/impl/XSSFilterImplTest.java
+++ b/src/test/java/org/apache/sling/xss/impl/XSSFilterImplTest.java
@@ -71,6 +71,11 @@ public class XSSFilterImplTest {
         });
         testData.add(
                 new Object[] 
{"&#x6a;&#x61;&#x76;&#x61;&#x73;&#x63;&#x72;&#x69;&#x70;&#x74;&#x3a;alert(1)", 
false});
+        // HTML5-only named character references (unknown to unescapeHtml4) 
and numeric references
+        // without a terminating semicolon are still decoded by browsers 
before URL parsing
+        testData.add(new Object[] {"java&Tab;script:alert(1)", false});
+        testData.add(new Object[] {"java&NewLine;script&colon;alert(1)", 
false});
+        testData.add(new Object[] {"&#106avascript:alert(1)", false});
         testData.add(new Object[] {"%-12", false});
         testData.add(new Object[] {"/promotion/25%/", false});
         testData.add(new Object[] {"#", true});
@@ -218,6 +223,76 @@ public class XSSFilterImplTest {
                 "Expected the fallback sanitizer to remove javascript: hrefs, 
but got: " + filtered);
     }
 
+    @Test
+    public void 
testUnicodeUnescaperDecodesHtml5NamedEntitiesNotOnlyTabNewlineAndColon() {
+        // spot-check a few of the HTML5-only named references beyond 
&Tab;/&NewLine;/&colon; that are
+        // exercised by the javascript-scheme-bypass tests, to guard against 
the entity table silently
+        // losing entries or mapping to the wrong character
+        assertEquals("a(b)c/d?e", 
XSSFilterImpl.UNICODE_UNESCAPER.translate("a&lpar;b&rpar;c&sol;d&quest;e"));
+        assertEquals("[x]", 
XSSFilterImpl.UNICODE_UNESCAPER.translate("&lsqb;x&rsqb;"));
+    }
+
+    @Test
+    public void testUnicodeUnescaperNamedEntitiesAreCaseSensitive() {
+        // HTML5 named references are case-sensitive; "&tab;" (lower-case) is 
not a valid reference and
+        // must be left untouched, unlike "&Tab;"
+        assertEquals("&tab;", 
XSSFilterImpl.UNICODE_UNESCAPER.translate("&tab;"));
+        assertEquals("\t", XSSFilterImpl.UNICODE_UNESCAPER.translate("&Tab;"));
+    }
+
+    @Test
+    public void testNumericEntityUnescaperDecimalReferenceWithoutSemicolon() {
+        assertEquals("javascript", 
XSSFilterImpl.NUMERIC_ENTITY_UNESCAPER.translate("&#106avascript"));
+    }
+
+    @Test
+    public void testNumericEntityUnescaperDecimalReferenceWithSemicolon() {
+        assertEquals("javascript", 
XSSFilterImpl.NUMERIC_ENTITY_UNESCAPER.translate("&#106;avascript"));
+    }
+
+    @Test
+    public void testNumericEntityUnescaperHexReferenceWithoutSemicolon() {
+        assertEquals("Junk", 
XSSFilterImpl.NUMERIC_ENTITY_UNESCAPER.translate("&#x4Aunk"));
+    }
+
+    @Test
+    public void testNumericEntityUnescaperHexReferenceWithSemicolon() {
+        assertEquals("Junk", 
XSSFilterImpl.NUMERIC_ENTITY_UNESCAPER.translate("&#x4A;unk"));
+    }
+
+    @Test
+    public void 
testNumericEntityUnescaperHexReferenceConsumesTrailingHexLetters() {
+        // unlike decimal references, a hex reference without a terminating 
semicolon keeps consuming
+        // digits as long as they are valid hex digits - including letters 
a-f/A-F - so "&#x6Aa;bc" is
+        // parsed as the single hex value 0x6AA, not as 0x6A followed by the 
literal text "a;bc"
+        String expected = new String(Character.toChars(0x6AA)) + "bc";
+        assertEquals(expected, 
XSSFilterImpl.NUMERIC_ENTITY_UNESCAPER.translate("&#x6Aa;bc"));
+    }
+
+    @Test
+    public void 
testNumericEntityUnescaperOutOfRangeCodePointDecodesToReplacementCharacter() {
+        // browsers decode a numeric reference above the maximum Unicode code 
point to U+FFFD rather
+        // than rejecting it
+        assertEquals("�", 
XSSFilterImpl.NUMERIC_ENTITY_UNESCAPER.translate("&#2000000;"));
+    }
+
+    @Test
+    public void 
testNumericEntityUnescaperOverflowingDigitsDecodeToReplacementCharacter() {
+        // a digit sequence too large to fit in an int must not throw 
NumberFormatException out of the
+        // translator; it is treated the same as an out-of-range code point
+        assertEquals("�", 
XSSFilterImpl.NUMERIC_ENTITY_UNESCAPER.translate("&#99999999999999;"));
+    }
+
+    @Test
+    public void 
testNumericEntityUnescaperIncompleteHexReferenceIsLeftLiteral() {
+        assertEquals("&#x", 
XSSFilterImpl.NUMERIC_ENTITY_UNESCAPER.translate("&#x"));
+    }
+
+    @Test
+    public void 
testNumericEntityUnescaperReferenceWithoutDigitsIsLeftLiteral() {
+        assertEquals("&#abc", 
XSSFilterImpl.NUMERIC_ENTITY_UNESCAPER.translate("&#abc"));
+    }
+
     private static @NotNull InputStream getPolicyFileAsStream() {
         return Objects.requireNonNull(
                 
XSSFilterImplTest.class.getClassLoader().getResourceAsStream(XSSFilterImpl.EMBEDDED_POLICY_PATH),

Reply via email to