/**
* 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.cxf.rs.security.cors;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;
import javax.annotation.Priority;
import javax.ws.rs.HttpMethod;
import javax.ws.rs.Priorities;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerRequestFilter;
import javax.ws.rs.container.ContainerResponseContext;
import javax.ws.rs.container.ContainerResponseFilter;
import javax.ws.rs.container.PreMatching;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.Provider;
import org.apache.cxf.common.util.ReflectionUtil;
import org.apache.cxf.jaxrs.impl.MetadataMap;
import org.apache.cxf.jaxrs.model.ClassResourceInfo;
import org.apache.cxf.jaxrs.model.OperationResourceInfo;
import org.apache.cxf.jaxrs.model.URITemplate;
import org.apache.cxf.jaxrs.utils.HttpUtils;
import org.apache.cxf.jaxrs.utils.JAXRSUtils;
import org.apache.cxf.message.Message;
import org.apache.cxf.phase.AbstractPhaseInterceptor;
import org.apache.cxf.phase.Phase;
/**
* A single class that provides both an input and an output filter for CORS, following
* http://www.w3.org/TR/cors/. The input filter examines the input headers. If the request is valid, it stores the
* information in the Exchange to allow the response handler to add the appropriate headers to the response.
* If you need complex or subtle control of the behavior here (e.g. clearing the prefight cache) you might be
* better off reading the source of this class and implementing this inside your service.
*
* This class will perform preflight processing even if there is a resource method annotated
* to handle @OPTIONS,
* <em>unless</em> that method is annotated as follows:
* <pre>
* @LocalPreflight
* </pre>
* or unless the <tt>defaultOptionsMethodsHandlePreflight</tt> property of this class is set to <tt>true</tt>.
*/
@Provider
@PreMatching
@Priority(Priorities.AUTHENTICATION - 1)
public class CrossOriginResourceSharingFilter implements ContainerRequestFilter,
ContainerResponseFilter {
private static final Pattern SPACE_PATTERN = Pattern.compile(" ");
private static final Pattern FIELD_COMMA_PATTERN = Pattern.compile(",");
private static final String LOCAL_PREFLIGHT = "local_preflight";
private static final String LOCAL_PREFLIGHT_ORIGIN = "local_preflight.origin";
private static final String LOCAL_PREFLIGHT_METHOD = "local_preflight.method";
private static final String PREFLIGHT_PASSED = "preflight_passed";
private static final String PREFLIGHT_FAILED = "preflight_failed";
private static final String SIMPLE_REQUEST = "simple_request";
@Context
private HttpHeaders headers;
/**
* This would be a rather painful list to maintain for real, since it's entirely dependent on the
* deployment.
*/
private List<String> allowOrigins = Collections.emptyList();
private List<String> allowHeaders = Collections.emptyList();
private boolean allowCredentials;
private List<String> exposeHeaders = Collections.emptyList();
private Integer maxAge;
private Integer preflightFailStatus = 200;
private boolean defaultOptionsMethodsHandlePreflight;
private boolean findResourceMethod = true;
private boolean blockCorsIfUnauthorized;
private <T extends Annotation> T getAnnotation(Method m,
Class<T> annClass) {
if (m == null) {
return null;
}
return ReflectionUtil.getAnnotationForMethodOrContainingClass(m, annClass);
}
@Override
public void filter(ContainerRequestContext context) {
Message m = JAXRSUtils.getCurrentMessage();
String httpMethod = (String)m.get(Message.HTTP_REQUEST_METHOD);
if (HttpMethod.OPTIONS.equals(httpMethod)) {
Response r = preflightRequest(m);
if (r != null) {
context.abortWith(r);
}
} else if (findResourceMethod) {
Method method = findResourceMethod ? getResourceMethod(m, httpMethod) : null;
simpleRequest(m, method);
} else {
m.getInterceptorChain().add(new CorsInInterceptor());
}
}
private Response simpleRequest(Message m, Method resourceMethod) {
CrossOriginResourceSharing ann =
getAnnotation(resourceMethod, CrossOriginResourceSharing.class);
List<String> headerOriginValues = getHeaderValues(CorsHeaderConstants.HEADER_ORIGIN, true);
// 5.1.1 there has to be an origin
if (headerOriginValues == null || headerOriginValues.size() == 0) {
return null;
}
// 5.1.2 check all the origins
if (!effectiveAllowOrigins(ann, headerOriginValues)) {
return null;
}
// handle 5.1.3
setAllowOriginAndCredentials(m, ann, headerOriginValues);
// 5.1.4
List<String> effectiveExposeHeaders = effectiveExposeHeaders(ann);
if (effectiveExposeHeaders != null && effectiveExposeHeaders.size() != 0) {
m.getExchange().put(CorsHeaderConstants.HEADER_AC_EXPOSE_HEADERS, effectiveExposeHeaders);
}
// note what kind of processing we're doing.
m.getExchange().put(CrossOriginResourceSharingFilter.class.getName(), SIMPLE_REQUEST);
return null;
}
/**
* handle preflight.
*
* Note that preflight is a bit of a parasite on OPTIONS. The class may still have an options method,
* and, if it does, it will be invoked, and it will respond however it likes. The response will
* have additional headers based on what happens here.
*
* @param m the incoming message.
* @return
*/
//CHECKSTYLE:OFF
private Response preflightRequest(Message m) {
// Validate main CORS preflight properties (origin, method)
// even if Local preflight is requested
// 5.2.1 -- must have origin, must have one origin.
List<String> headerOriginValues = getHeaderValues(CorsHeaderConstants.HEADER_ORIGIN, true);
if (headerOriginValues == null || headerOriginValues.size() != 1) {
return null;
}
String origin = headerOriginValues.get(0);
// 5.2.3 must have access-control-request-method, must be single-valued
// we should reject parse errors but we cannot.
List<String> requestMethodValues = getHeaderValues(CorsHeaderConstants.HEADER_AC_REQUEST_METHOD, false);
if (requestMethodValues == null || requestMethodValues.size() != 1) {
return createPreflightResponse(m, false);
}
String requestMethod = requestMethodValues.get(0);
/*
* Ask JAX-RS runtime to validate that the matching resource method actually exists.
*/
Method method = null;
if (findResourceMethod) {
method = getResourceMethod(m, requestMethod);
if (method == null) {
return null;
}
}
/*
* What to do if the resource class indeed has a method annotated with @OPTIONS
* that is matched by this request? We go ahead and do this job unless the request
* has one of our annotations on it (or its parent class) indicating 'localPreflight' --
* or the defaultOptionsMethodsHandlePreflight flag is true.
*/
LocalPreflight preflightAnnotation = null;
if (!defaultOptionsMethodsHandlePreflight) {
Method optionsMethod = getResourceMethod(m, "OPTIONS");
if (optionsMethod != null) {
preflightAnnotation = getAnnotation(optionsMethod, LocalPreflight.class);
}
}
if (preflightAnnotation != null || defaultOptionsMethodsHandlePreflight) {
m.put(LOCAL_PREFLIGHT, "true");
m.put(LOCAL_PREFLIGHT_ORIGIN, origin);
m.put(LOCAL_PREFLIGHT_METHOD, method);
return null; // let the resource method take all responsibility.
}
CrossOriginResourceSharing ann = getAnnotation(method, CrossOriginResourceSharing.class);
/* We aren't required to have any annotation at all. If no annotation,
* the properties of this filter make all the decisions.
*/
// 5.2.2 must be on the list or we must be matching *.
if (!effectiveAllowOrigins(ann, Collections.singletonList(origin))) {
return createPreflightResponse(m, false);
}
// 5.2.4 get list of request headers. we should reject parse errors but we cannot.
List<String> requestHeaders = getHeaderValues(CorsHeaderConstants.HEADER_AC_REQUEST_HEADERS, false);
// 5.2.5 reject if the method is not on the list.
// This was indirectly enforced by getCorsMethod()
// 5.2.6 reject if the header is not listed.
if (!effectiveAllowHeaders(ann, requestHeaders)) {
return createPreflightResponse(m, false);
}
// 5.2.9 add allow-methods; we pass them from here to the output filter which actually adds them.
m.getExchange().put(CorsHeaderConstants.HEADER_AC_ALLOW_METHODS, Arrays.asList(requestMethod));
// 5.2.10 add allow-headers; we pass them from here to the output filter which actually adds them.
m.getExchange().put(CorsHeaderConstants.HEADER_AC_ALLOW_HEADERS, requestHeaders);
// 5.2.8 max-age lives in the output filter.
if (effectiveMaxAge(ann) != null) {
m.getExchange().put(CorsHeaderConstants.HEADER_AC_MAX_AGE, effectiveMaxAge(ann).toString());
}
// 5.2.7 is in here.
setAllowOriginAndCredentials(m, ann, headerOriginValues);
return createPreflightResponse(m, true);
}
//CHECKSTYLE:ON
private Response createPreflightResponse(Message m, boolean passed) {
m.getExchange().put(CrossOriginResourceSharingFilter.class.getName(),
passed ? PREFLIGHT_PASSED : PREFLIGHT_FAILED);
int status = passed ? 200 : preflightFailStatus;
return Response.status(status).build();
}
private Method getResourceMethod(Message m, String httpMethod) {
String requestUri = HttpUtils.getPathToMatch(m, true);
List<ClassResourceInfo> resources = JAXRSUtils.getRootResources(m);
Map<ClassResourceInfo, MultivaluedMap<String, String>> matchedResources =
JAXRSUtils.selectResourceClass(resources, requestUri, m);
if (matchedResources == null) {
return null;
}
MultivaluedMap<String, String> values = new MetadataMap<String, String>();
OperationResourceInfo ori = findPreflightMethod(matchedResources, requestUri, httpMethod, values, m);
return ori == null ? null : ori.getAnnotatedMethod();
}
private OperationResourceInfo findPreflightMethod(
Map<ClassResourceInfo, MultivaluedMap<String, String>> matchedResources,
String requestUri,
String httpMethod,
MultivaluedMap<String, String> values,
Message m) {
final String contentType = MediaType.WILDCARD;
final MediaType acceptType = MediaType.WILDCARD_TYPE;
OperationResourceInfo ori = JAXRSUtils.findTargetMethod(matchedResources,
m, httpMethod, values,
contentType,
Collections.singletonList(acceptType),
false,
false);
if (ori == null) {
return null;
}
if (ori.isSubResourceLocator()) {
Class<?> cls = ori.getMethodToInvoke().getReturnType();
ClassResourceInfo subcri = ori.getClassResourceInfo().getSubResource(cls, cls);
if (subcri == null) {
return null;
} else {
MultivaluedMap<String, String> newValues = new MetadataMap<String, String>();
newValues.putAll(values);
return findPreflightMethod(Collections.singletonMap(subcri, newValues),
values.getFirst(URITemplate.FINAL_MATCH_GROUP),
httpMethod,
newValues,
m);
}
} else {
return ori;
}
}
private void setAllowOriginAndCredentials(Message m,
CrossOriginResourceSharing ann,
List<String> headerOriginValues) {
boolean allowCreds = effectiveAllowCredentials(ann);
m.getExchange().put(CorsHeaderConstants.HEADER_AC_ALLOW_CREDENTIALS, allowCreds);
String originResponse;
if (!allowCreds && effectiveAllowAllOrigins(ann)) {
originResponse = "*";
} else {
originResponse = concatValues(headerOriginValues, true);
}
m.getExchange().put(CorsHeaderConstants.HEADER_ORIGIN, originResponse);
}
@Override
public void filter(ContainerRequestContext requestContext,
ContainerResponseContext responseContext) {
Message m = JAXRSUtils.getCurrentMessage();
String op = (String)m.getExchange().get(CrossOriginResourceSharingFilter.class.getName());
if (op == null || op == PREFLIGHT_FAILED) {
return;
}
if (responseContext.getStatus() == Response.Status.UNAUTHORIZED.getStatusCode()
&& blockCorsIfUnauthorized) {
return;
}
/* Common to simple and preflight */
responseContext.getHeaders().putSingle(CorsHeaderConstants.HEADER_AC_ALLOW_ORIGIN,
m.getExchange().get(CorsHeaderConstants.HEADER_ORIGIN));
responseContext.getHeaders().putSingle(CorsHeaderConstants.HEADER_AC_ALLOW_CREDENTIALS,
m.getExchange().get(CorsHeaderConstants.HEADER_AC_ALLOW_CREDENTIALS));
if (SIMPLE_REQUEST.equals(op)) {
/* 5.1.4 expose headers */
List<String> effectiveExposeHeaders
= getHeadersFromInput(m, CorsHeaderConstants.HEADER_AC_EXPOSE_HEADERS);
if (effectiveExposeHeaders != null) {
addHeaders(responseContext, CorsHeaderConstants.HEADER_AC_EXPOSE_HEADERS,
effectiveExposeHeaders, false);
}
// if someone wants to clear the cache, we can't help them.
} else {
// 5.2.8 max-age
String maValue = (String)m.getExchange().get(CorsHeaderConstants.HEADER_AC_MAX_AGE);
if (maValue != null) {
responseContext.getHeaders().putSingle(CorsHeaderConstants.HEADER_AC_MAX_AGE, maValue);
}
// 5.2.9 add allowed methods
/*
* Currently, input side just lists the one requested method, and spec endorses that.
*/
addHeaders(responseContext, CorsHeaderConstants.HEADER_AC_ALLOW_METHODS,
getHeadersFromInput(m, CorsHeaderConstants.HEADER_AC_ALLOW_METHODS), false);
// 5.2.10 add allowed headers
List<String> rqAllowedHeaders = getHeadersFromInput(m,
CorsHeaderConstants.HEADER_AC_ALLOW_HEADERS);
if (rqAllowedHeaders != null) {
addHeaders(responseContext, CorsHeaderConstants.HEADER_AC_ALLOW_HEADERS, rqAllowedHeaders, false);
}
}
}
private boolean effectiveAllowAllOrigins(CrossOriginResourceSharing ann) {
if (ann != null) {
return ann.allowAllOrigins();
} else {
return allowOrigins.isEmpty();
}
}
private boolean effectiveAllowCredentials(CrossOriginResourceSharing ann) {
if (ann != null) {
return ann.allowCredentials();
} else {
return allowCredentials;
}
}
private boolean effectiveAllowOrigins(CrossOriginResourceSharing ann, List<String> origins) {
if (effectiveAllowAllOrigins(ann)) {
return true;
}
List<String> actualOrigins = Collections.emptyList();
if (ann != null) {
actualOrigins = Arrays.asList(ann.allowOrigins());
}
if (actualOrigins.isEmpty()) {
actualOrigins = allowOrigins;
}
return actualOrigins.containsAll(origins);
}
private boolean effectiveAllowAnyHeaders(CrossOriginResourceSharing ann) {
if (ann != null) {
return ann.allowHeaders().length == 0;
} else {
return allowHeaders.isEmpty();
}
}
private boolean effectiveAllowHeaders(CrossOriginResourceSharing ann, List<String> aHeaders) {
if (effectiveAllowAnyHeaders(ann)) {
return true;
}
List<String> actualHeaders = null;
if (ann != null) {
actualHeaders = Arrays.asList(ann.allowHeaders());
} else {
actualHeaders = allowHeaders;
}
Set<String> actualHeadersSet = new TreeSet<String>(String.CASE_INSENSITIVE_ORDER);
actualHeadersSet.addAll(actualHeaders);
return actualHeadersSet.containsAll(aHeaders);
}
private List<String> effectiveExposeHeaders(CrossOriginResourceSharing ann) {
List<String> actualExposeHeaders = null;
if (ann != null) {
actualExposeHeaders = Arrays.asList(ann.exposeHeaders());
} else {
actualExposeHeaders = exposeHeaders;
}
return actualExposeHeaders;
}
private Integer effectiveMaxAge(CrossOriginResourceSharing ann) {
if (ann != null) {
int ma = ann.maxAge();
if (ma < 0) {
return null;
} else {
return Integer.valueOf(ma);
}
} else {
return maxAge;
}
}
/**
* Function called to grab a list of strings left behind by the input side.
* @param m
* @param key
* @return
*/
@SuppressWarnings("unchecked")
private List<String> getHeadersFromInput(Message m, String key) {
Object obj = m.getExchange().get(key);
if (obj instanceof List<?>) {
return (List<String>)obj;
}
return null;
}
/**
* CORS uses one header containing space-separated values (Origin) and then
* a raft of #field-name productions, which parse on commas and optional spaces.
* @param key
* @param spaceSeparated
* @return
*/
private List<String> getHeaderValues(String key, boolean spaceSeparated) {
List<String> values = headers.getRequestHeader(key);
Pattern splitPattern;
if (spaceSeparated) {
splitPattern = SPACE_PATTERN;
} else {
splitPattern = FIELD_COMMA_PATTERN;
}
List<String> results = new ArrayList<>();
if (values != null) {
for (String value : values) {
String[] items = splitPattern.split(value);
for (String item : items) {
results.add(item.trim());
}
}
}
return results;
}
private void addHeaders(ContainerResponseContext responseContext,
String key, List<String> values, boolean spaceSeparated) {
String sb = concatValues(values, spaceSeparated);
responseContext.getHeaders().putSingle(key, sb);
}
private String concatValues(List<String> values, boolean spaceSeparated) {
StringBuilder sb = new StringBuilder();
for (int x = 0; x < values.size(); x++) {
sb.append(values.get(x));
if (x != values.size() - 1) {
if (spaceSeparated) {
sb.append(" ");
} else {
sb.append(", ");
}
}
}
return sb.toString();
}
/**
* The origin strings to allow. An empty list allows all origins.
*
* @param allowedOrigins a list of case-sensitive origin strings.
*/
public void setAllowOrigins(List<String> allowedOrigins) {
this.allowOrigins = allowedOrigins;
}
/** @return the list of allowed origins. */
public List<String> getAllowOrigins() {
return allowOrigins;
}
public List<String> getAllowHeaders() {
return allowHeaders;
}
/**
* The list of allowed headers for preflight checks. Section 5.2.6
*
* @param allowedHeaders a list of permitted headers.
*/
public void setAllowHeaders(List<String> allowedHeaders) {
this.allowHeaders = allowedHeaders;
}
public List<String> getExposeHeaders() {
return exposeHeaders;
}
public Integer getMaxAge() {
return maxAge;
}
public boolean isAllowCredentials() {
return allowCredentials;
}
/**
* The value for the Access-Control-Allow-Credentials header. If false, no header is added. If true, the
* header is added with the value 'true'.
*
* @param allowCredentials
*/
public void setAllowCredentials(boolean allowCredentials) {
this.allowCredentials = allowCredentials;
}
/**
* A list of non-simple headers to be exposed via Access-Control-Expose-Headers.
*
* @param exposeHeaders the list of (case-sensitive) header names.
*/
public void setExposeHeaders(List<String> exposeHeaders) {
this.exposeHeaders = exposeHeaders;
}
/**
* The value for Access-Control-Max-Age.
*
* @param maxAge An integer 'delta-seconds' or null. If null, no header is added.
*/
public void setMaxAge(Integer maxAge) {
this.maxAge = maxAge;
}
/**
* Preflight error response status, default is 200.
*
* @param status HTTP status code.
*/
public void setPreflightErrorStatus(Integer status) {
this.preflightFailStatus = status;
}
/**
* What to do when a preflight request comes along for a resource that has a handler method for
* \@OPTIONS and there is no <tt>@{@link CrossResourceSharing}(localPreflight = val)</tt>
* annotation on the method. If this is <tt>true</tt>, then the filter
* defers to the resource class method.
* If this is false, then this filter performs preflight processing.
* @param defaultOptionsMethodsHandlePreflight true to defer to resource methods.
*/
public void setDefaultOptionsMethodsHandlePreflight(boolean defaultOptionsMethodsHandlePreflight) {
this.defaultOptionsMethodsHandlePreflight = defaultOptionsMethodsHandlePreflight;
}
public void setFindResourceMethod(boolean findResourceMethod) {
this.findResourceMethod = findResourceMethod;
}
public void setBlockCorsIfUnauthorized(boolean blockCorsIfUnauthorized) {
this.blockCorsIfUnauthorized = blockCorsIfUnauthorized;
}
private class CorsInInterceptor extends AbstractPhaseInterceptor<Message> {
CorsInInterceptor() {
super(Phase.PRE_INVOKE);
}
@Override
public void handleMessage(Message message) {
OperationResourceInfo ori = message.getExchange().get(OperationResourceInfo.class);
simpleRequest(message, ori.getAnnotatedMethod());
}
}
}