blob: 2c5571eecc18f88fcf8de69d8cd712ed89087987 [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.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import javax.annotation.Nonnull;
import org.apache.commons.lang3.StringEscapeUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.resource.LoginException;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceResolverFactory;
import org.apache.sling.api.resource.observation.ExternalResourceChangeListener;
import org.apache.sling.api.resource.observation.ResourceChange;
import org.apache.sling.api.resource.observation.ResourceChangeListener;
import org.apache.sling.serviceusermapping.ServiceUserMapped;
import org.apache.sling.xss.ProtectionContext;
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.Reference;
import org.owasp.validator.html.model.Attribute;
import org.owasp.validator.html.model.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This class implements the <code>XSSFilter</code> using the Antisamy XSS protection library found at
* <a href="http://code.google.com/p/owaspantisamy/">http://code.google.com/p/owaspantisamy/</a>.
*/
@Component(
service = {ResourceChangeListener.class, XSSFilter.class},
property = {
Constants.SERVICE_VENDOR + "=The Apache Software Foundation",
ResourceChangeListener.CHANGES + "=ADDED",
ResourceChangeListener.CHANGES + "=CHANGED",
ResourceChangeListener.CHANGES + "=REMOVED",
ResourceChangeListener.PATHS + "=" + XSSFilterImpl.DEFAULT_POLICY_PATH
}
)
public class XSSFilterImpl implements XSSFilter, ResourceChangeListener, ExternalResourceChangeListener {
private final Logger logger = LoggerFactory.getLogger(XSSFilterImpl.class);
// Default href configuration copied from the config.xml supplied with AntiSamy
static final Attribute DEFAULT_HREF_ATTRIBUTE = new Attribute(
"href",
Arrays.asList(
Pattern.compile("([\\p{L}\\p{M}*+\\p{N}\\\\\\.\\#@\\$%\\+&;\\-_~,\\?=/!\\*\\(\\)]*|\\#(\\w)+)"),
Pattern.compile("(\\s)*((ht|f)tp(s?)://|mailto:)[\\p{L}\\p{M}*+\\p{N}]+[\\p{L}\\p{M}*+\\p{N}\\p{Zs}\\.\\#@\\$%\\+&;:\\-_~,\\?=/!\\*\\(\\)]*(\\s)*")
),
Collections.<String>emptyList(),
"removeAttribute", ""
);
static final String DEFAULT_POLICY_PATH = "sling/xss/config.xml";
private static final String EMBEDDED_POLICY_PATH = "SLING-INF/content/config.xml";
private static final int DEFAULT_POLICY_CACHE_SIZE = 128;
private PolicyHandler defaultHandler;
private Attribute hrefAttribute;
// available contexts
private final XSSFilterRule htmlHtmlContext = new HtmlToHtmlContentContext();
private final XSSFilterRule plainHtmlContext = new PlainTextToHtmlContentContext();
// policies cache
private final Map<String, PolicyHandler> policies = new ConcurrentHashMap<>();
@Reference
private ResourceResolverFactory resourceResolverFactory;
@Reference
private ServiceUserMapped serviceUserMapped;
@Override
public void onChange(@Nonnull List<ResourceChange> resourceChanges) {
for (ResourceChange change : resourceChanges) {
if (change.getPath().endsWith(DEFAULT_POLICY_PATH)) {
logger.info("Detected policy file change ({}) at {}. Updating default handler.", change.getType().name(), change.getPath());
updateDefaultHandler();
}
}
}
@Override
public boolean check(final ProtectionContext context, final String src) {
return this.check(context, src, null);
}
@Override
public String filter(final String src) {
return this.filter(XSSFilter.DEFAULT_CONTEXT, src);
}
@Override
public String filter(final ProtectionContext context, final String src) {
return this.filter(context, src, null);
}
@Override
public boolean isValidHref(String url) {
if (StringUtils.isEmpty(url)) {
return true;
}
try {
String decodedURL = 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
*/
String xmlDecodedURL = StringEscapeUtils.unescapeXml(decodedURL);
if (xmlDecodedURL.equals(url) || xmlDecodedURL.equals(decodedURL)) {
return runHrefValidation(url);
}
return runHrefValidation(xmlDecodedURL);
} catch (UnsupportedEncodingException e) {
logger.error("Unable to decode url: {}.", url);
}
return false;
}
private boolean runHrefValidation(@Nonnull String url) {
// Same logic as in org.owasp.validator.html.scan.MagicSAXFilter.startElement()
boolean isValid = hrefAttribute.containsAllowedValue(url.toLowerCase());
if (!isValid) {
isValid = hrefAttribute.matchesAllowedExpression(url);
}
return isValid;
}
@Activate
protected void activate() {
// load default handler
updateDefaultHandler();
}
/*
The following methods are not part of the API. Client-code dependency to these methods is risky as they can be removed at any
point in time from the implementation.
*/
public boolean check(final ProtectionContext context, final String src, final String policy) {
final XSSFilterRule ctx = this.getFilterRule(context);
PolicyHandler handler = null;
if (ctx.supportsPolicy()) {
if (policy == null || (handler = policies.get(policy)) == null) {
handler = defaultHandler;
}
}
return ctx.check(handler, src);
}
public String filter(final ProtectionContext context, final String src, final String policy) {
if (src == null) {
return "";
}
final XSSFilterRule ctx = this.getFilterRule(context);
PolicyHandler handler = null;
if (ctx.supportsPolicy()) {
if (policy == null || (handler = policies.get(policy)) == null) {
handler = defaultHandler;
}
}
return ctx.filter(handler, src);
}
public void setDefaultPolicy(InputStream policyStream) throws Exception {
setDefaultHandler(new PolicyHandler(policyStream));
}
public void resetDefaultPolicy() {
updateDefaultHandler();
}
public void loadPolicy(String policyName, InputStream policyStream) throws Exception {
if (policies.size() < DEFAULT_POLICY_CACHE_SIZE) {
PolicyHandler policyHandler = new PolicyHandler(policyStream);
policies.put(policyName, policyHandler);
}
}
public void unloadPolicy(String policyName) {
policies.remove(policyName);
}
public boolean hasPolicy(String policyName) {
return policies.containsKey(policyName);
}
private synchronized void updateDefaultHandler() {
this.defaultHandler = null;
try (final ResourceResolver xssResourceResolver = resourceResolverFactory.getServiceResourceResolver(null)) {
Resource policyResource = xssResourceResolver.getResource(DEFAULT_POLICY_PATH);
if (policyResource != null) {
try (InputStream policyStream = policyResource.adaptTo(InputStream.class)) {
setDefaultHandler(new PolicyHandler(policyStream));
logger.info("Installed default policy from {}.", policyResource.getPath());
} catch (Exception e) {
Throwable[] suppressed = e.getSuppressed();
if (suppressed.length > 0) {
for (Throwable t : suppressed) {
logger.error("Unable to load policy from " + policyResource.getPath(), t);
}
}
logger.error("Unable to load policy from " + policyResource.getPath(), e);
}
}
} catch (final LoginException e) {
logger.error("Unable to load the default policy file.", e);
}
if (defaultHandler == null) {
// the content was not installed but the service is active; let's use the embedded file for the default handler
logger.info("Could not find a policy file at the default location {}. Attempting to use the default resource embedded in" +
" the bundle.", DEFAULT_POLICY_PATH);
try (InputStream policyStream = this.getClass().getClassLoader().getResourceAsStream(EMBEDDED_POLICY_PATH)) {
setDefaultHandler(new PolicyHandler(policyStream));
logger.info("Installed default policy from the embedded {} file from the bundle.", EMBEDDED_POLICY_PATH);
} catch (Exception e) {
Throwable[] suppressed = e.getSuppressed();
if (suppressed.length > 0) {
for (Throwable t : suppressed) {
logger.error("Unable to load policy from embedded policy file.", t);
}
}
logger.error("Unable to load policy from embedded policy file.", e);
}
}
if (defaultHandler == null) {
throw new IllegalStateException("Cannot load a default policy handler.");
}
}
/**
* Get the filter rule context.
*/
private XSSFilterRule getFilterRule(final ProtectionContext context) {
if (context == null) {
throw new NullPointerException("context");
}
if (context == ProtectionContext.HTML_HTML_CONTENT) {
return this.htmlHtmlContext;
}
return this.plainHtmlContext;
}
private void setDefaultHandler(PolicyHandler defaultHandler) {
Tag linkTag = defaultHandler.getPolicy().getTagByLowercaseName("a");
Attribute hrefAttribute = (linkTag != null) ? linkTag.getAttributeByName("href") : null;
if (hrefAttribute == null) {
// Fallback to default configuration
hrefAttribute = DEFAULT_HREF_ATTRIBUTE;
}
this.defaultHandler = defaultHandler;
this.hrefAttribute = hrefAttribute;
}
}