blob: 3c28b69ef9f9dc5d28d25d5cb8ca6269765817de [file] [log] [blame]
Jake Slack03928ae2014-05-13 18:41:56 -07001//
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
19package org.eclipse.jetty.servlets;
20
21import java.io.IOException;
22import java.util.ArrayList;
23import java.util.Arrays;
24import java.util.Enumeration;
25import java.util.List;
26import java.util.regex.Matcher;
27import java.util.regex.Pattern;
28import javax.servlet.Filter;
29import javax.servlet.FilterChain;
30import javax.servlet.FilterConfig;
31import javax.servlet.ServletException;
32import javax.servlet.ServletRequest;
33import javax.servlet.ServletResponse;
34import javax.servlet.http.HttpServletRequest;
35import javax.servlet.http.HttpServletResponse;
36
37import org.eclipse.jetty.util.log.Log;
38import 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 * &lt;web-app ...&gt;
80 * ...
81 * &lt;filter&gt;
82 * &lt;filter-name&gt;cross-origin&lt;/filter-name&gt;
83 * &lt;filter-class&gt;org.eclipse.jetty.servlets.CrossOriginFilter&lt;/filter-class&gt;
84 * &lt;/filter&gt;
85 * &lt;filter-mapping&gt;
86 * &lt;filter-name&gt;cross-origin&lt;/filter-name&gt;
87 * &lt;url-pattern&gt;/cometd/*&lt;/url-pattern&gt;
88 * &lt;/filter-mapping&gt;
89 * ...
90 * &lt;/web-app&gt;
91 * </pre></p>
92 */
93public 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}