1 // 2 // ======================================================================== 3 // Copyright (c) 1995-2014 Mort Bay Consulting Pty. Ltd. 4 // ------------------------------------------------------------------------ 5 // All rights reserved. This program and the accompanying materials 6 // are made available under the terms of the Eclipse Public License v1.0 7 // and Apache License v2.0 which accompanies this distribution. 8 // 9 // The Eclipse Public License is available at 10 // http://www.eclipse.org/legal/epl-v10.html 11 // 12 // The Apache License v2.0 is available at 13 // http://www.opensource.org/licenses/apache2.0.php 14 // 15 // You may elect to redistribute this code under either of these licenses. 16 // ======================================================================== 17 // 18 19 package org.eclipse.jetty.servlets; 20 21 import java.io.IOException; 22 import java.util.ArrayList; 23 import java.util.Arrays; 24 import java.util.Enumeration; 25 import java.util.List; 26 import java.util.regex.Matcher; 27 import java.util.regex.Pattern; 28 import javax.servlet.Filter; 29 import javax.servlet.FilterChain; 30 import javax.servlet.FilterConfig; 31 import javax.servlet.ServletException; 32 import javax.servlet.ServletRequest; 33 import javax.servlet.ServletResponse; 34 import javax.servlet.http.HttpServletRequest; 35 import javax.servlet.http.HttpServletResponse; 36 37 import org.eclipse.jetty.util.log.Log; 38 import org.eclipse.jetty.util.log.Logger; 39 40 /** 41 * <p>Implementation of the 42 * <a href="http://www.w3.org/TR/cors/">cross-origin resource sharing</a>.</p> 43 * <p>A typical example is to use this filter to allow cross-domain 44 * <a href="http://cometd.org">cometd</a> communication using the standard 45 * long polling transport instead of the JSONP transport (that is less 46 * efficient and less reactive to failures).</p> 47 * <p>This filter allows the following configuration parameters: 48 * <ul> 49 * <li><b>allowedOrigins</b>, a comma separated list of origins that are 50 * allowed to access the resources. Default value is <b>*</b>, meaning all 51 * origins.<br /> 52 * If an allowed origin contains one or more * characters (for example 53 * http://*.domain.com), then "*" characters are converted to ".*", "." 54 * characters are escaped to "\." and the resulting allowed origin 55 * interpreted as a regular expression.<br /> 56 * Allowed origins can therefore be more complex expressions such as 57 * https?://*.domain.[a-z]{3} that matches http or https, multiple subdomains 58 * and any 3 letter top-level domain (.com, .net, .org, etc.).</li> 59 * <li><b>allowedMethods</b>, a comma separated list of HTTP methods that 60 * are allowed to be used when accessing the resources. Default value is 61 * <b>GET,POST,HEAD</b></li> 62 * <li><b>allowedHeaders</b>, a comma separated list of HTTP headers that 63 * are allowed to be specified when accessing the resources. Default value 64 * is <b>X-Requested-With,Content-Type,Accept,Origin</b></li> 65 * <li><b>preflightMaxAge</b>, the number of seconds that preflight requests 66 * can be cached by the client. Default value is <b>1800</b> seconds, or 30 67 * minutes</li> 68 * <li><b>allowCredentials</b>, a boolean indicating if the resource allows 69 * requests with credentials. Default value is <b>false</b></li> 70 * <li><b>exposeHeaders</b>, a comma separated list of HTTP headers that 71 * are allowed to be exposed on the client. Default value is the 72 * <b>empty list</b></li> 73 * <li><b>chainPreflight</b>, if true preflight requests are chained to their 74 * target resource for normal handling (as an OPTION request). Otherwise the 75 * filter will response to the preflight. Default is true.</li> 76 * </ul></p> 77 * <p>A typical configuration could be: 78 * <pre> 79 * <web-app ...> 80 * ... 81 * <filter> 82 * <filter-name>cross-origin</filter-name> 83 * <filter-class>org.eclipse.jetty.servlets.CrossOriginFilter</filter-class> 84 * </filter> 85 * <filter-mapping> 86 * <filter-name>cross-origin</filter-name> 87 * <url-pattern>/cometd/*</url-pattern> 88 * </filter-mapping> 89 * ... 90 * </web-app> 91 * </pre></p> 92 */ 93 public class CrossOriginFilter implements Filter 94 { 95 private static final Logger LOG = Log.getLogger(CrossOriginFilter.class); 96 97 // Request headers 98 private static final String ORIGIN_HEADER = "Origin"; 99 public static final String ACCESS_CONTROL_REQUEST_METHOD_HEADER = "Access-Control-Request-Method"; 100 public static final String ACCESS_CONTROL_REQUEST_HEADERS_HEADER = "Access-Control-Request-Headers"; 101 // Response headers 102 public static final String ACCESS_CONTROL_ALLOW_ORIGIN_HEADER = "Access-Control-Allow-Origin"; 103 public static final String ACCESS_CONTROL_ALLOW_METHODS_HEADER = "Access-Control-Allow-Methods"; 104 public static final String ACCESS_CONTROL_ALLOW_HEADERS_HEADER = "Access-Control-Allow-Headers"; 105 public static final String ACCESS_CONTROL_MAX_AGE_HEADER = "Access-Control-Max-Age"; 106 public static final String ACCESS_CONTROL_ALLOW_CREDENTIALS_HEADER = "Access-Control-Allow-Credentials"; 107 public static final String ACCESS_CONTROL_EXPOSE_HEADERS_HEADER = "Access-Control-Expose-Headers"; 108 // Implementation constants 109 public static final String ALLOWED_ORIGINS_PARAM = "allowedOrigins"; 110 public static final String ALLOWED_METHODS_PARAM = "allowedMethods"; 111 public static final String ALLOWED_HEADERS_PARAM = "allowedHeaders"; 112 public static final String PREFLIGHT_MAX_AGE_PARAM = "preflightMaxAge"; 113 public static final String ALLOW_CREDENTIALS_PARAM = "allowCredentials"; 114 public static final String EXPOSED_HEADERS_PARAM = "exposedHeaders"; 115 public static final String OLD_CHAIN_PREFLIGHT_PARAM = "forwardPreflight"; 116 public static final String CHAIN_PREFLIGHT_PARAM = "chainPreflight"; 117 private static final String ANY_ORIGIN = "*"; 118 private static final List<String> SIMPLE_HTTP_METHODS = Arrays.asList("GET", "POST", "HEAD"); 119 120 private boolean anyOriginAllowed; 121 private List<String> allowedOrigins = new ArrayList<String>(); 122 private List<String> allowedMethods = new ArrayList<String>(); 123 private List<String> allowedHeaders = new ArrayList<String>(); 124 private List<String> exposedHeaders = new ArrayList<String>(); 125 private int preflightMaxAge; 126 private boolean allowCredentials; 127 private boolean chainPreflight; 128 init(FilterConfig config)129 public void init(FilterConfig config) throws ServletException 130 { 131 String allowedOriginsConfig = config.getInitParameter(ALLOWED_ORIGINS_PARAM); 132 if (allowedOriginsConfig == null) 133 allowedOriginsConfig = "*"; 134 String[] allowedOrigins = allowedOriginsConfig.split(","); 135 for (String allowedOrigin : allowedOrigins) 136 { 137 allowedOrigin = allowedOrigin.trim(); 138 if (allowedOrigin.length() > 0) 139 { 140 if (ANY_ORIGIN.equals(allowedOrigin)) 141 { 142 anyOriginAllowed = true; 143 this.allowedOrigins.clear(); 144 break; 145 } 146 else 147 { 148 this.allowedOrigins.add(allowedOrigin); 149 } 150 } 151 } 152 153 String allowedMethodsConfig = config.getInitParameter(ALLOWED_METHODS_PARAM); 154 if (allowedMethodsConfig == null) 155 allowedMethodsConfig = "GET,POST,HEAD"; 156 allowedMethods.addAll(Arrays.asList(allowedMethodsConfig.split(","))); 157 158 String allowedHeadersConfig = config.getInitParameter(ALLOWED_HEADERS_PARAM); 159 if (allowedHeadersConfig == null) 160 allowedHeadersConfig = "X-Requested-With,Content-Type,Accept,Origin"; 161 allowedHeaders.addAll(Arrays.asList(allowedHeadersConfig.split(","))); 162 163 String preflightMaxAgeConfig = config.getInitParameter(PREFLIGHT_MAX_AGE_PARAM); 164 if (preflightMaxAgeConfig == null) 165 preflightMaxAgeConfig = "1800"; // Default is 30 minutes 166 try 167 { 168 preflightMaxAge = Integer.parseInt(preflightMaxAgeConfig); 169 } 170 catch (NumberFormatException x) 171 { 172 LOG.info("Cross-origin filter, could not parse '{}' parameter as integer: {}", PREFLIGHT_MAX_AGE_PARAM, preflightMaxAgeConfig); 173 } 174 175 String allowedCredentialsConfig = config.getInitParameter(ALLOW_CREDENTIALS_PARAM); 176 if (allowedCredentialsConfig == null) 177 allowedCredentialsConfig = "true"; 178 allowCredentials = Boolean.parseBoolean(allowedCredentialsConfig); 179 180 String exposedHeadersConfig = config.getInitParameter(EXPOSED_HEADERS_PARAM); 181 if (exposedHeadersConfig == null) 182 exposedHeadersConfig = ""; 183 exposedHeaders.addAll(Arrays.asList(exposedHeadersConfig.split(","))); 184 185 String chainPreflightConfig = config.getInitParameter(OLD_CHAIN_PREFLIGHT_PARAM); 186 if (chainPreflightConfig!=null) // TODO remove this 187 LOG.warn("DEPRECATED CONFIGURATION: Use "+CHAIN_PREFLIGHT_PARAM+ " instead of "+OLD_CHAIN_PREFLIGHT_PARAM); 188 else 189 chainPreflightConfig = config.getInitParameter(CHAIN_PREFLIGHT_PARAM); 190 if (chainPreflightConfig == null) 191 chainPreflightConfig = "true"; 192 chainPreflight = Boolean.parseBoolean(chainPreflightConfig); 193 194 if (LOG.isDebugEnabled()) 195 { 196 LOG.debug("Cross-origin filter configuration: " + 197 ALLOWED_ORIGINS_PARAM + " = " + allowedOriginsConfig + ", " + 198 ALLOWED_METHODS_PARAM + " = " + allowedMethodsConfig + ", " + 199 ALLOWED_HEADERS_PARAM + " = " + allowedHeadersConfig + ", " + 200 PREFLIGHT_MAX_AGE_PARAM + " = " + preflightMaxAgeConfig + ", " + 201 ALLOW_CREDENTIALS_PARAM + " = " + allowedCredentialsConfig + "," + 202 EXPOSED_HEADERS_PARAM + " = " + exposedHeadersConfig + "," + 203 CHAIN_PREFLIGHT_PARAM + " = " + chainPreflightConfig 204 ); 205 } 206 } 207 doFilter(ServletRequest request, ServletResponse response, FilterChain chain)208 public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException 209 { 210 handle((HttpServletRequest)request, (HttpServletResponse)response, chain); 211 } 212 handle(HttpServletRequest request, HttpServletResponse response, FilterChain chain)213 private void handle(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException 214 { 215 String origin = request.getHeader(ORIGIN_HEADER); 216 // Is it a cross origin request ? 217 if (origin != null && isEnabled(request)) 218 { 219 if (originMatches(origin)) 220 { 221 if (isSimpleRequest(request)) 222 { 223 LOG.debug("Cross-origin request to {} is a simple cross-origin request", request.getRequestURI()); 224 handleSimpleResponse(request, response, origin); 225 } 226 else if (isPreflightRequest(request)) 227 { 228 LOG.debug("Cross-origin request to {} is a preflight cross-origin request", request.getRequestURI()); 229 handlePreflightResponse(request, response, origin); 230 if (chainPreflight) 231 LOG.debug("Preflight cross-origin request to {} forwarded to application", request.getRequestURI()); 232 else 233 return; 234 } 235 else 236 { 237 LOG.debug("Cross-origin request to {} is a non-simple cross-origin request", request.getRequestURI()); 238 handleSimpleResponse(request, response, origin); 239 } 240 } 241 else 242 { 243 LOG.debug("Cross-origin request to " + request.getRequestURI() + " with origin " + origin + " does not match allowed origins " + allowedOrigins); 244 } 245 } 246 247 chain.doFilter(request, response); 248 } 249 isEnabled(HttpServletRequest request)250 protected boolean isEnabled(HttpServletRequest request) 251 { 252 // WebSocket clients such as Chrome 5 implement a version of the WebSocket 253 // protocol that does not accept extra response headers on the upgrade response 254 for (Enumeration connections = request.getHeaders("Connection"); connections.hasMoreElements();) 255 { 256 String connection = (String)connections.nextElement(); 257 if ("Upgrade".equalsIgnoreCase(connection)) 258 { 259 for (Enumeration upgrades = request.getHeaders("Upgrade"); upgrades.hasMoreElements();) 260 { 261 String upgrade = (String)upgrades.nextElement(); 262 if ("WebSocket".equalsIgnoreCase(upgrade)) 263 return false; 264 } 265 } 266 } 267 return true; 268 } 269 originMatches(String originList)270 private boolean originMatches(String originList) 271 { 272 if (anyOriginAllowed) 273 return true; 274 275 if (originList.trim().length() == 0) 276 return false; 277 278 String[] origins = originList.split(" "); 279 for (String origin : origins) 280 { 281 if (origin.trim().length() == 0) 282 continue; 283 284 for (String allowedOrigin : allowedOrigins) 285 { 286 if (allowedOrigin.contains("*")) 287 { 288 Matcher matcher = createMatcher(origin,allowedOrigin); 289 if (matcher.matches()) 290 return true; 291 } 292 else if (allowedOrigin.equals(origin)) 293 { 294 return true; 295 } 296 } 297 } 298 return false; 299 } 300 createMatcher(String origin, String allowedOrigin)301 private Matcher createMatcher(String origin, String allowedOrigin) 302 { 303 String regex = parseAllowedWildcardOriginToRegex(allowedOrigin); 304 Pattern pattern = Pattern.compile(regex); 305 return pattern.matcher(origin); 306 } 307 parseAllowedWildcardOriginToRegex(String allowedOrigin)308 private String parseAllowedWildcardOriginToRegex(String allowedOrigin) 309 { 310 String regex = allowedOrigin.replace(".","\\."); 311 return regex.replace("*",".*"); // we want to be greedy here to match multiple subdomains, thus we use .* 312 } 313 isSimpleRequest(HttpServletRequest request)314 private boolean isSimpleRequest(HttpServletRequest request) 315 { 316 String method = request.getMethod(); 317 if (SIMPLE_HTTP_METHODS.contains(method)) 318 { 319 // TODO: implement better detection of simple headers 320 // The specification says that for a request to be simple, custom request headers must be simple. 321 // Here for simplicity I just check if there is a Access-Control-Request-Method header, 322 // which is required for preflight requests 323 return request.getHeader(ACCESS_CONTROL_REQUEST_METHOD_HEADER) == null; 324 } 325 return false; 326 } 327 isPreflightRequest(HttpServletRequest request)328 private boolean isPreflightRequest(HttpServletRequest request) 329 { 330 String method = request.getMethod(); 331 if (!"OPTIONS".equalsIgnoreCase(method)) 332 return false; 333 if (request.getHeader(ACCESS_CONTROL_REQUEST_METHOD_HEADER) == null) 334 return false; 335 return true; 336 } 337 handleSimpleResponse(HttpServletRequest request, HttpServletResponse response, String origin)338 private void handleSimpleResponse(HttpServletRequest request, HttpServletResponse response, String origin) 339 { 340 response.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN_HEADER, origin); 341 if (allowCredentials) 342 response.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS_HEADER, "true"); 343 if (!exposedHeaders.isEmpty()) 344 response.setHeader(ACCESS_CONTROL_EXPOSE_HEADERS_HEADER, commify(exposedHeaders)); 345 } 346 handlePreflightResponse(HttpServletRequest request, HttpServletResponse response, String origin)347 private void handlePreflightResponse(HttpServletRequest request, HttpServletResponse response, String origin) 348 { 349 boolean methodAllowed = isMethodAllowed(request); 350 if (!methodAllowed) 351 return; 352 boolean headersAllowed = areHeadersAllowed(request); 353 if (!headersAllowed) 354 return; 355 response.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN_HEADER, origin); 356 if (allowCredentials) 357 response.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS_HEADER, "true"); 358 if (preflightMaxAge > 0) 359 response.setHeader(ACCESS_CONTROL_MAX_AGE_HEADER, String.valueOf(preflightMaxAge)); 360 response.setHeader(ACCESS_CONTROL_ALLOW_METHODS_HEADER, commify(allowedMethods)); 361 response.setHeader(ACCESS_CONTROL_ALLOW_HEADERS_HEADER, commify(allowedHeaders)); 362 } 363 isMethodAllowed(HttpServletRequest request)364 private boolean isMethodAllowed(HttpServletRequest request) 365 { 366 String accessControlRequestMethod = request.getHeader(ACCESS_CONTROL_REQUEST_METHOD_HEADER); 367 LOG.debug("{} is {}", ACCESS_CONTROL_REQUEST_METHOD_HEADER, accessControlRequestMethod); 368 boolean result = false; 369 if (accessControlRequestMethod != null) 370 result = allowedMethods.contains(accessControlRequestMethod); 371 LOG.debug("Method {} is" + (result ? "" : " not") + " among allowed methods {}", accessControlRequestMethod, allowedMethods); 372 return result; 373 } 374 areHeadersAllowed(HttpServletRequest request)375 private boolean areHeadersAllowed(HttpServletRequest request) 376 { 377 String accessControlRequestHeaders = request.getHeader(ACCESS_CONTROL_REQUEST_HEADERS_HEADER); 378 LOG.debug("{} is {}", ACCESS_CONTROL_REQUEST_HEADERS_HEADER, accessControlRequestHeaders); 379 boolean result = true; 380 if (accessControlRequestHeaders != null) 381 { 382 String[] headers = accessControlRequestHeaders.split(","); 383 for (String header : headers) 384 { 385 boolean headerAllowed = false; 386 for (String allowedHeader : allowedHeaders) 387 { 388 if (header.trim().equalsIgnoreCase(allowedHeader.trim())) 389 { 390 headerAllowed = true; 391 break; 392 } 393 } 394 if (!headerAllowed) 395 { 396 result = false; 397 break; 398 } 399 } 400 } 401 LOG.debug("Headers [{}] are" + (result ? "" : " not") + " among allowed headers {}", accessControlRequestHeaders, allowedHeaders); 402 return result; 403 } 404 commify(List<String> strings)405 private String commify(List<String> strings) 406 { 407 StringBuilder builder = new StringBuilder(); 408 for (int i = 0; i < strings.size(); ++i) 409 { 410 if (i > 0) builder.append(","); 411 String string = strings.get(i); 412 builder.append(string); 413 } 414 return builder.toString(); 415 } 416 destroy()417 public void destroy() 418 { 419 anyOriginAllowed = false; 420 allowedOrigins.clear(); 421 allowedMethods.clear(); 422 allowedHeaders.clear(); 423 preflightMaxAge = 0; 424 allowCredentials = false; 425 } 426 } 427