Jake Slack | 03928ae | 2014-05-13 18:41:56 -0700 | [diff] [blame] | 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 | |
| 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 | |
| 208 | public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException |
| 209 | { |
| 210 | handle((HttpServletRequest)request, (HttpServletResponse)response, chain); |
| 211 | } |
| 212 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | } |