blob: 4c62098e73320690c08d1b43ac4343b1a7d5b91b [file] [log] [blame]
/*******************************************************************************
* 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 java.io.StringReader;
import java.io.StringWriter;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.Nonnull;
import javax.json.Json;
import javax.json.JsonReaderFactory;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;
import org.apache.commons.lang3.StringEscapeUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.sling.xss.ProtectionContext;
import org.apache.sling.xss.XSSAPI;
import org.apache.sling.xss.XSSFilter;
import org.osgi.framework.Constants;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import org.owasp.encoder.Encode;
import org.owasp.esapi.ESAPI;
import org.owasp.esapi.Validator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xml.sax.InputSource;
import org.xml.sax.XMLReader;
@Component(service = XSSAPI.class,
property = {
Constants.SERVICE_VENDOR + "=The Apache Software Foundation"
})
public class XSSAPIImpl implements XSSAPI {
private final Logger LOGGER = LoggerFactory.getLogger(XSSAPIImpl.class);
@Reference
private XSSFilter xssFilter;
private final Validator validator = ESAPI.validator();
private static final Pattern PATTERN_AUTO_DIMENSION = Pattern.compile("['\"]?auto['\"]?");
private SAXParserFactory factory;
private volatile JsonReaderFactory jsonReaderFactory;
@Activate
protected void activate() {
factory = SAXParserFactory.newInstance();
factory.setValidating(false);
factory.setNamespaceAware(true);
try {
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
} catch (Exception e) {
LOGGER.error("SAX parser configuration error: " + e.getMessage(), e);
}
Map<String, Object> config = new HashMap<>();
config.put("org.apache.johnzon.supports-comments", true);
jsonReaderFactory = Json.createReaderFactory(config);
}
@Deactivate
protected void deactivate() {
factory = null;
jsonReaderFactory = null;
}
// =============================================================================================
// VALIDATORS
//
/**
* @see org.apache.sling.xss.XSSAPI#getValidInteger(String, int)
*/
@Override
public Integer getValidInteger(String integer, int defaultValue) {
if (integer != null && integer.length() > 0) {
try {
return validator.getValidInteger("XSS", integer, -2000000000, 2000000000, false);
} catch (Exception e) {
// ignore
}
}
// fall through to default if empty, null, or validation failure
return defaultValue;
}
/**
* @see org.apache.sling.xss.XSSAPI#getValidLong(String, long)
*/
@Override
public Long getValidLong(String source, long defaultValue) {
if (source != null && source.length() > 0) {
try {
LongValidationRule ivr = new LongValidationRule( "number", ESAPI.encoder(), -9000000000000000000L, 9000000000000000000L );
ivr.setAllowNull(false);
return ivr.getValid("XSS", source);
} catch (Exception e) {
// ignore
}
}
// fall through to default if empty, null, or validation failure
return defaultValue;
}
/**
* @see org.apache.sling.xss.XSSAPI#getValidDouble(String, double)
*/
@Override
public Double getValidDouble(String source, double defaultValue) {
if (source != null && source.length() > 0) {
try {
return validator.getValidDouble("XSS", source, 0d, Double.MAX_VALUE, false);
} catch (Exception e) {
// ignore
}
}
// fall through to default if empty, null, or validation failure
return defaultValue;
}
/**
* @see org.apache.sling.xss.XSSAPI#getValidDimension(String, String)
*/
@Override
public String getValidDimension(String dimension, String defaultValue) {
if (dimension != null && dimension.length() > 0) {
if (PATTERN_AUTO_DIMENSION.matcher(dimension).matches()) {
return "\"auto\"";
}
try {
return validator.getValidInteger("XSS", dimension, -10000, 10000, false).toString();
} catch (Exception e) {
// ignore
}
}
// fall through to default if empty, null, or validation failure
return defaultValue;
}
private static final String MANGLE_NAMESPACE_OUT_SUFFIX = ":";
private static final String MANGLE_NAMESPACE_OUT = "/([^:/]+):";
private static final Pattern MANGLE_NAMESPACE_PATTERN = Pattern.compile(MANGLE_NAMESPACE_OUT);
private static final String MANGLE_NAMESPACE_IN_SUFFIX = "_";
private static final String MANGLE_NAMESPACE_IN_PREFIX = "/_";
private static final String SCHEME_PATTERN = "://";
private String mangleNamespaces(String absPath) {
if (absPath != null) {
// check for absolute urls
final int schemeIndex = absPath.indexOf(SCHEME_PATTERN);
final String manglePath;
final String prefix;
if (schemeIndex != -1) {
final int pathIndex = absPath.indexOf("/", schemeIndex + 3);
if (pathIndex != -1) {
prefix = absPath.substring(0, pathIndex);
manglePath = absPath.substring(pathIndex);
} else {
prefix = absPath;
manglePath = "";
}
} else {
prefix = "";
manglePath = absPath;
}
if (manglePath.contains(MANGLE_NAMESPACE_OUT_SUFFIX)) {
final Matcher m = MANGLE_NAMESPACE_PATTERN.matcher(manglePath);
final StringBuffer buf = new StringBuffer();
while (m.find()) {
final String replacement = MANGLE_NAMESPACE_IN_PREFIX + m.group(1) + MANGLE_NAMESPACE_IN_SUFFIX;
m.appendReplacement(buf, replacement);
}
m.appendTail(buf);
absPath = prefix + buf.toString();
}
}
return absPath;
}
/**
* @see org.apache.sling.xss.XSSAPI#getValidHref(String)
*/
@Override
@Nonnull
public String getValidHref(final String url) {
if (StringUtils.isNotEmpty(url)) {
try {
String unescapedURL = URLDecoder.decode(url, StandardCharsets.UTF_8.name());
/*
StringEscapeUtils is deprecated starting with version 3.6 of commons-lang3, however the indicated replacement comes from
commons-text, which is not an OSGi bundle
*/
unescapedURL = StringEscapeUtils.unescapeXml(unescapedURL);
// Percent-encode characters that are not allowed in unquoted
// HTML attributes: ", ', >, <, ` and space. We don't encode =
// since this would break links with query parameters.
String encodedUrl = unescapedURL.replaceAll("\"", "%22")
.replaceAll("'", "%27")
.replaceAll(">", "%3E")
.replaceAll("<", "%3C")
.replaceAll("`", "%60")
.replaceAll(" ", "%20");
int qMarkIx = encodedUrl.indexOf('?');
if (qMarkIx > 0) {
encodedUrl = encodedUrl.substring(0, qMarkIx) + encodedUrl.substring(qMarkIx).replaceAll(":", "%3A");
}
encodedUrl = mangleNamespaces(encodedUrl);
if (xssFilter.isValidHref(encodedUrl)) {
return encodedUrl;
}
} catch (UnsupportedEncodingException e) {
LOGGER.error("Unable to decode url: {}.", url);
}
}
// fall through to empty string
return "";
}
/**
* @see org.apache.sling.xss.XSSAPI#getValidJSToken(String, String)
*/
@Override
public String getValidJSToken(String token, String defaultValue) {
if (token != null && token.length() > 0) {
token = token.trim();
String q = token.substring(0, 1);
if (q.matches("['\"]") && token.endsWith(q)) {
String literal = token.substring(1, token.length() - 1);
return q + encodeForJSString(literal) + q;
} else if (token.matches("[0-9a-zA-Z_$][0-9a-zA-Z_$.]*")) {
return token;
}
}
// fall through to default value
return defaultValue;
}
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/#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 */
private static final String STRING = "\"(?:(?!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 */
private static final String FUNCTION = IDENTIFIER + "\\((?:(?:" + 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 */
private static final String URL = "url\\((?:(?:" + URL_UNQUOTED + ")|(?:" + STRING + "))\\)";
/** composite regular expression for style token validation */
private static final String CSS_TOKEN = "(?i)" // case insensitive
+ "(?:" + NUMBER + ")"
+ "|(?:" + DIMENSION + ")"
+ "|(?:" + PERCENT + ")"
+ "|(?:" + HEX_DIGITS + ")"
+ "|(?:" + IDENTIFIER + ")"
+ "|(?:" + STRING + ")"
+ "|(?:" + FUNCTION + ")"
+ "|(?:" + URL + ")";
/**
* @see org.apache.sling.xss.XSSAPI#getValidStyleToken(String, String)
*/
@Override
public String getValidStyleToken(String token, String defaultValue) {
if (token != null && token.length() > 0 && token.matches(CSS_TOKEN)) {
return token;
}
return defaultValue;
}
/**
* @see org.apache.sling.xss.XSSAPI#getValidCSSColor(String, String)
*/
@Override
public String getValidCSSColor(String color, String defaultColor) {
if (color != null && color.length() > 0) {
color = color.trim();
/*
* Avoid security implications by including only the characters required to specify colors in hex
* or functional notation. Critical characters disallowed: x (as in expression(...)),
* u (as in url(...)) and semi colon (as in escaping the context of the color value).
*/
if (color.matches("(?i)[#a-fghlrs(+0-9-.%,) \\t\\n\\x0B\\f\\r]+")) {
return color;
}
// named color values
if (color.matches("(?i)[a-zA-Z \\t\\n\\x0B\\f\\r]+")) {
return color;
}
}
return defaultColor;
}
/**
* @see org.apache.sling.xss.XSSAPI#getValidMultiLineComment(String, String)
*/
@Override
public String getValidMultiLineComment(String comment, String defaultComment) {
if (comment != null && !comment.contains("*/")) {
return comment;
}
return defaultComment;
}
/**
* @see org.apache.sling.xss.XSSAPI#getValidJSON(String, String)
*/
@Override
public String getValidJSON(String json, String defaultJson) {
if (json == null) {
return getValidJSON(defaultJson, "");
}
json = json.trim();
if ("".equals(json)) {
return "";
}
int curlyIx = json.indexOf("{");
int straightIx = json.indexOf("[");
if (curlyIx >= 0 && (curlyIx < straightIx || straightIx < 0)) {
try {
StringWriter output = new StringWriter();
Json.createGenerator(output).write(jsonReaderFactory.createReader(new StringReader(json)).readObject()).close();
return output.getBuffer().toString();
} catch (Exception e) {
LOGGER.debug("JSON validation failed: " + e.getMessage(), e);
}
} else {
try {
StringWriter output = new StringWriter();
Json.createGenerator(output).write(jsonReaderFactory.createReader(new StringReader(json)).readArray()).close();
return output.getBuffer().toString();
} catch (Exception e) {
LOGGER.debug("JSON validation failed: " + e.getMessage(), e);
}
}
return getValidJSON(defaultJson, "");
}
/**
* @see org.apache.sling.xss.XSSAPI#getValidXML(String, String)
*/
@Override
public String getValidXML(String xml, String defaultXml) {
if (xml == null) {
return getValidXML(defaultXml, "");
}
xml = xml.trim();
if ("".equals(xml)) {
return "";
}
try {
SAXParser parser = factory.newSAXParser();
XMLReader reader = parser.getXMLReader();
reader.parse(new InputSource(new StringReader(xml)));
return xml;
} catch (Exception e) {
LOGGER.debug("XML validation failed: " + e.getMessage(), e);
}
return getValidXML(defaultXml, "");
}
// =============================================================================================
// ENCODERS
//
/**
* @see org.apache.sling.xss.XSSAPI#encodeForHTML(String)
*/
@Override
public String encodeForHTML(String source) {
return source == null ? null : Encode.forHtml(source);
}
/**
* @see org.apache.sling.xss.XSSAPI#encodeForHTMLAttr(String)
*/
@Override
public String encodeForHTMLAttr(String source) {
return source == null ? null : Encode.forHtmlAttribute(source);
}
/**
* @see org.apache.sling.xss.XSSAPI#encodeForXML(String)
*/
@Override
public String encodeForXML(String source) {
return source == null ? null : Encode.forXml(source);
}
/**
* @see org.apache.sling.xss.XSSAPI#encodeForXMLAttr(String)
*/
@Override
public String encodeForXMLAttr(String source) {
return source == null ? null : Encode.forXmlAttribute(source);
}
/**
* @see org.apache.sling.xss.XSSAPI#encodeForJSString(String)
*/
@Override
public String encodeForJSString(String source) {
return source == null ? null : Encode.forJavaScript(source).replace("\\-", "\\u002D");
}
/**
* @see org.apache.sling.xss.XSSAPI#encodeForCSSString(String)
*/
@Override
public String encodeForCSSString(String source) {
return source == null ? null : Encode.forCssString(source);
}
// =============================================================================================
// FILTERS
//
/**
* @see org.apache.sling.xss.XSSAPI#filterHTML(String)
*/
@Override
@Nonnull
public String filterHTML(String source) {
return xssFilter.filter(ProtectionContext.HTML_HTML_CONTENT, source);
}
}