View Javadoc
1   //
2   //  ========================================================================
3   //  Copyright (c) 1995-2016 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.Collections;
25  import java.util.Enumeration;
26  import java.util.List;
27  import java.util.regex.Matcher;
28  import java.util.regex.Pattern;
29  
30  import javax.servlet.Filter;
31  import javax.servlet.FilterChain;
32  import javax.servlet.FilterConfig;
33  import javax.servlet.ServletException;
34  import javax.servlet.ServletRequest;
35  import javax.servlet.ServletResponse;
36  import javax.servlet.http.HttpServletRequest;
37  import javax.servlet.http.HttpServletResponse;
38  
39  import org.eclipse.jetty.util.StringUtil;
40  import org.eclipse.jetty.util.log.Log;
41  import org.eclipse.jetty.util.log.Logger;
42  
43  /**
44   * Implementation of the
45   * <a href="http://www.w3.org/TR/cors/">cross-origin resource sharing</a>.
46   * <p>
47   * A typical example is to use this filter to allow cross-domain
48   * <a href="http://cometd.org">cometd</a> communication using the standard
49   * long polling transport instead of the JSONP transport (that is less
50   * efficient and less reactive to failures).
51   * <p>
52   * This filter allows the following configuration parameters:
53   * <dl>
54   * <dt>allowedOrigins</dt>
55   * <dd>a comma separated list of origins that are
56   * allowed to access the resources. Default value is <b>*</b>, meaning all
57   * origins.
58   * <p>
59   * If an allowed origin contains one or more * characters (for example
60   * http://*.domain.com), then "*" characters are converted to ".*", "."
61   * characters are escaped to "\." and the resulting allowed origin
62   * interpreted as a regular expression.
63   * <p>
64   * Allowed origins can therefore be more complex expressions such as
65   * https?://*.domain.[a-z]{3} that matches http or https, multiple subdomains
66   * and any 3 letter top-level domain (.com, .net, .org, etc.).</dd>
67   * 
68   * <dt>allowedTimingOrigins</dt>
69   * <dd>a comma separated list of origins that are
70   * allowed to time the resource. Default value is the empty string, meaning
71   * no origins.
72   * <p>
73   * The check whether the timing header is set, will be performed only if
74   * the user gets general access to the resource using the <b>allowedOrigins</b>.
75   *
76   * <dt>allowedMethods</dt>
77   * <dd>a comma separated list of HTTP methods that
78   * are allowed to be used when accessing the resources. Default value is
79   * <b>GET,POST,HEAD</b></dd>
80   * 
81   * 
82   * <dt>allowedHeaders</dt>
83   * <dd>a comma separated list of HTTP headers that
84   * are allowed to be specified when accessing the resources. Default value
85   * is <b>X-Requested-With,Content-Type,Accept,Origin</b>. If the value is a single "*",
86   * this means that any headers will be accepted.</dd>
87   * 
88   * <dt>preflightMaxAge</dt>
89   * <dd>the number of seconds that preflight requests
90   * can be cached by the client. Default value is <b>1800</b> seconds, or 30
91   * minutes</dd>
92   * 
93   * <dt>allowCredentials</dt>
94   * <dd>a boolean indicating if the resource allows
95   * requests with credentials. Default value is <b>true</b></dd>
96   * 
97   * <dt>exposedHeaders</dt>
98   * <dd>a comma separated list of HTTP headers that
99   * are allowed to be exposed on the client. Default value is the
100  * <b>empty list</b></dd>
101  * 
102  * <dt>chainPreflight</dt>
103  * <dd>if true preflight requests are chained to their
104  * target resource for normal handling (as an OPTION request).  Otherwise the
105  * filter will response to the preflight. Default is <b>true</b>.</dd>
106  * 
107  * </dl>
108  * A typical configuration could be:
109  * <pre>
110  * &lt;web-app ...&gt;
111  *     ...
112  *     &lt;filter&gt;
113  *         &lt;filter-name&gt;cross-origin&lt;/filter-name&gt;
114  *         &lt;filter-class&gt;org.eclipse.jetty.servlets.CrossOriginFilter&lt;/filter-class&gt;
115  *     &lt;/filter&gt;
116  *     &lt;filter-mapping&gt;
117  *         &lt;filter-name&gt;cross-origin&lt;/filter-name&gt;
118  *         &lt;url-pattern&gt;/cometd/*&lt;/url-pattern&gt;
119  *     &lt;/filter-mapping&gt;
120  *     ...
121  * &lt;/web-app&gt;
122  * </pre>
123  */
124 public class CrossOriginFilter implements Filter
125 {
126     private static final Logger LOG = Log.getLogger(CrossOriginFilter.class);
127 
128     // Request headers
129     private static final String ORIGIN_HEADER = "Origin";
130     public static final String ACCESS_CONTROL_REQUEST_METHOD_HEADER = "Access-Control-Request-Method";
131     public static final String ACCESS_CONTROL_REQUEST_HEADERS_HEADER = "Access-Control-Request-Headers";
132     // Response headers
133     public static final String ACCESS_CONTROL_ALLOW_ORIGIN_HEADER = "Access-Control-Allow-Origin";
134     public static final String ACCESS_CONTROL_ALLOW_METHODS_HEADER = "Access-Control-Allow-Methods";
135     public static final String ACCESS_CONTROL_ALLOW_HEADERS_HEADER = "Access-Control-Allow-Headers";
136     public static final String ACCESS_CONTROL_MAX_AGE_HEADER = "Access-Control-Max-Age";
137     public static final String ACCESS_CONTROL_ALLOW_CREDENTIALS_HEADER = "Access-Control-Allow-Credentials";
138     public static final String ACCESS_CONTROL_EXPOSE_HEADERS_HEADER = "Access-Control-Expose-Headers";
139     public static final String TIMING_ALLOW_ORIGIN_HEADER = "Timing-Allow-Origin";
140     // Implementation constants
141     public static final String ALLOWED_ORIGINS_PARAM = "allowedOrigins";
142     public static final String ALLOWED_TIMING_ORIGINS_PARAM = "allowedTimingOrigins";
143     public static final String ALLOWED_METHODS_PARAM = "allowedMethods";
144     public static final String ALLOWED_HEADERS_PARAM = "allowedHeaders";
145     public static final String PREFLIGHT_MAX_AGE_PARAM = "preflightMaxAge";
146     public static final String ALLOW_CREDENTIALS_PARAM = "allowCredentials";
147     public static final String EXPOSED_HEADERS_PARAM = "exposedHeaders";
148     public static final String OLD_CHAIN_PREFLIGHT_PARAM = "forwardPreflight";
149     public static final String CHAIN_PREFLIGHT_PARAM = "chainPreflight";
150     private static final String ANY_ORIGIN = "*";
151     private static final String DEFAULT_ALLOWED_ORIGINS = "*";
152     private static final String DEFAULT_ALLOWED_TIMING_ORIGINS = "";
153     private static final List<String> SIMPLE_HTTP_METHODS = Arrays.asList("GET", "POST", "HEAD");
154     private static final List<String> DEFAULT_ALLOWED_METHODS = Arrays.asList("GET", "POST", "HEAD");
155     private static final List<String> DEFAULT_ALLOWED_HEADERS = Arrays.asList("X-Requested-With", "Content-Type", "Accept", "Origin");
156 
157     private boolean anyOriginAllowed;
158     private boolean anyTimingOriginAllowed;
159     private boolean anyHeadersAllowed;
160     private List<String> allowedOrigins = new ArrayList<String>();
161     private List<String> allowedTimingOrigins = new ArrayList<String>();
162     private List<String> allowedMethods = new ArrayList<String>();
163     private List<String> allowedHeaders = new ArrayList<String>();
164     private List<String> exposedHeaders = new ArrayList<String>();
165     private int preflightMaxAge;
166     private boolean allowCredentials;
167     private boolean chainPreflight;
168 
169     public void init(FilterConfig config) throws ServletException
170     {
171         String allowedOriginsConfig = config.getInitParameter(ALLOWED_ORIGINS_PARAM);
172         String allowedTimingOriginsConfig = config.getInitParameter(ALLOWED_TIMING_ORIGINS_PARAM);
173         
174         anyOriginAllowed = generateAllowedOrigins(allowedOrigins, allowedOriginsConfig, DEFAULT_ALLOWED_ORIGINS);
175         anyTimingOriginAllowed = generateAllowedOrigins(allowedTimingOrigins, allowedTimingOriginsConfig, DEFAULT_ALLOWED_TIMING_ORIGINS);
176 
177         String allowedMethodsConfig = config.getInitParameter(ALLOWED_METHODS_PARAM);
178         if (allowedMethodsConfig == null)
179             allowedMethods.addAll(DEFAULT_ALLOWED_METHODS);
180         else
181             allowedMethods.addAll(Arrays.asList(StringUtil.csvSplit(allowedMethodsConfig)));
182 
183         String allowedHeadersConfig = config.getInitParameter(ALLOWED_HEADERS_PARAM);
184         if (allowedHeadersConfig == null)
185             allowedHeaders.addAll(DEFAULT_ALLOWED_HEADERS);
186         else if ("*".equals(allowedHeadersConfig))
187             anyHeadersAllowed = true;
188         else
189             allowedHeaders.addAll(Arrays.asList(StringUtil.csvSplit(allowedHeadersConfig)));
190 
191         String preflightMaxAgeConfig = config.getInitParameter(PREFLIGHT_MAX_AGE_PARAM);
192         if (preflightMaxAgeConfig == null)
193             preflightMaxAgeConfig = "1800"; // Default is 30 minutes
194         try
195         {
196             preflightMaxAge = Integer.parseInt(preflightMaxAgeConfig);
197         }
198         catch (NumberFormatException x)
199         {
200             LOG.info("Cross-origin filter, could not parse '{}' parameter as integer: {}", PREFLIGHT_MAX_AGE_PARAM, preflightMaxAgeConfig);
201         }
202 
203         String allowedCredentialsConfig = config.getInitParameter(ALLOW_CREDENTIALS_PARAM);
204         if (allowedCredentialsConfig == null)
205             allowedCredentialsConfig = "true";
206         allowCredentials = Boolean.parseBoolean(allowedCredentialsConfig);
207 
208         String exposedHeadersConfig = config.getInitParameter(EXPOSED_HEADERS_PARAM);
209         if (exposedHeadersConfig == null)
210             exposedHeadersConfig = "";
211         exposedHeaders.addAll(Arrays.asList(StringUtil.csvSplit(exposedHeadersConfig)));
212 
213         String chainPreflightConfig = config.getInitParameter(OLD_CHAIN_PREFLIGHT_PARAM);
214         if (chainPreflightConfig != null)
215             LOG.warn("DEPRECATED CONFIGURATION: Use " + CHAIN_PREFLIGHT_PARAM + " instead of " + OLD_CHAIN_PREFLIGHT_PARAM);
216         else
217             chainPreflightConfig = config.getInitParameter(CHAIN_PREFLIGHT_PARAM);
218         if (chainPreflightConfig == null)
219             chainPreflightConfig = "true";
220         chainPreflight = Boolean.parseBoolean(chainPreflightConfig);
221 
222         if (LOG.isDebugEnabled())
223         {
224             LOG.debug("Cross-origin filter configuration: " +
225                             ALLOWED_ORIGINS_PARAM + " = " + allowedOriginsConfig + ", " +
226                             ALLOWED_TIMING_ORIGINS_PARAM + " = " + allowedTimingOriginsConfig + ", " +
227                             ALLOWED_METHODS_PARAM + " = " + allowedMethodsConfig + ", " +
228                             ALLOWED_HEADERS_PARAM + " = " + allowedHeadersConfig + ", " +
229                             PREFLIGHT_MAX_AGE_PARAM + " = " + preflightMaxAgeConfig + ", " +
230                             ALLOW_CREDENTIALS_PARAM + " = " + allowedCredentialsConfig + "," +
231                             EXPOSED_HEADERS_PARAM + " = " + exposedHeadersConfig + "," +
232                             CHAIN_PREFLIGHT_PARAM + " = " + chainPreflightConfig
233             );
234         }
235     }
236 
237     private boolean generateAllowedOrigins(List<String> allowedOriginStore, String allowedOriginsConfig, String defaultOrigin) 
238     {
239         if (allowedOriginsConfig == null)
240             allowedOriginsConfig = defaultOrigin;
241         String[] allowedOrigins = StringUtil.csvSplit(allowedOriginsConfig);
242         for (String allowedOrigin : allowedOrigins)
243         {
244             if (allowedOrigin.length() > 0)
245             {
246                 if (ANY_ORIGIN.equals(allowedOrigin))
247                 {
248                     allowedOriginStore.clear();
249                     return true;
250                 }
251                 else
252                 {
253                     allowedOriginStore.add(allowedOrigin);
254                 }
255             }
256         }
257         return false;
258     }
259     
260     public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException
261     {
262         handle((HttpServletRequest)request, (HttpServletResponse)response, chain);
263     }
264 
265     private void handle(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException
266     {
267         String origin = request.getHeader(ORIGIN_HEADER);
268         // Is it a cross origin request ?
269         if (origin != null && isEnabled(request))
270         {
271             if (anyOriginAllowed || originMatches(allowedOrigins, origin))
272             {
273                 if (isSimpleRequest(request))
274                 {
275                     LOG.debug("Cross-origin request to {} is a simple cross-origin request", request.getRequestURI());
276                     handleSimpleResponse(request, response, origin);
277                 }
278                 else if (isPreflightRequest(request))
279                 {
280                     LOG.debug("Cross-origin request to {} is a preflight cross-origin request", request.getRequestURI());
281                     handlePreflightResponse(request, response, origin);
282                     if (chainPreflight)
283                         LOG.debug("Preflight cross-origin request to {} forwarded to application", request.getRequestURI());
284                     else
285                         return;
286                 }
287                 else
288                 {
289                     LOG.debug("Cross-origin request to {} is a non-simple cross-origin request", request.getRequestURI());
290                     handleSimpleResponse(request, response, origin);
291                 }
292 
293                 if (anyTimingOriginAllowed || originMatches(allowedTimingOrigins, origin))
294                 {
295                     response.setHeader(TIMING_ALLOW_ORIGIN_HEADER, origin);
296                 }
297                 else
298                 {
299                     LOG.debug("Cross-origin request to " + request.getRequestURI() + " with origin " + origin + " does not match allowed timing origins " + allowedTimingOrigins);
300                 }
301             }
302             else
303             {
304                 LOG.debug("Cross-origin request to " + request.getRequestURI() + " with origin " + origin + " does not match allowed origins " + allowedOrigins);
305             }
306         }
307 
308         chain.doFilter(request, response);
309     }
310 
311     protected boolean isEnabled(HttpServletRequest request)
312     {
313         // WebSocket clients such as Chrome 5 implement a version of the WebSocket
314         // protocol that does not accept extra response headers on the upgrade response
315         for (Enumeration<String> connections = request.getHeaders("Connection"); connections.hasMoreElements();)
316         {
317             String connection = (String)connections.nextElement();
318             if ("Upgrade".equalsIgnoreCase(connection))
319             {
320                 for (Enumeration<String>  upgrades = request.getHeaders("Upgrade"); upgrades.hasMoreElements();)
321                 {
322                     String upgrade = (String)upgrades.nextElement();
323                     if ("WebSocket".equalsIgnoreCase(upgrade))
324                         return false;
325                 }
326             }
327         }
328         return true;
329     }
330 
331     private boolean originMatches(List<String> allowedOrigins, String originList)
332     {
333         if (originList.trim().length() == 0)
334             return false;
335 
336         String[] origins = originList.split(" ");
337         for (String origin : origins)
338         {
339             if (origin.trim().length() == 0)
340                 continue;
341 
342             for (String allowedOrigin : allowedOrigins)
343             {
344                 if (allowedOrigin.contains("*"))
345                 {
346                     Matcher matcher = createMatcher(origin, allowedOrigin);
347                     if (matcher.matches())
348                         return true;
349                 }
350                 else if (allowedOrigin.equals(origin))
351                 {
352                     return true;
353                 }
354             }
355         }
356         return false;
357     }
358 
359     private Matcher createMatcher(String origin, String allowedOrigin)
360     {
361         String regex = parseAllowedWildcardOriginToRegex(allowedOrigin);
362         Pattern pattern = Pattern.compile(regex);
363         return pattern.matcher(origin);
364     }
365 
366     private String parseAllowedWildcardOriginToRegex(String allowedOrigin)
367     {
368         String regex = allowedOrigin.replace(".", "\\.");
369         return regex.replace("*", ".*"); // we want to be greedy here to match multiple subdomains, thus we use .*
370     }
371 
372     private boolean isSimpleRequest(HttpServletRequest request)
373     {
374         String method = request.getMethod();
375         if (SIMPLE_HTTP_METHODS.contains(method))
376         {
377             // TODO: implement better detection of simple headers
378             // The specification says that for a request to be simple, custom request headers must be simple.
379             // Here for simplicity I just check if there is a Access-Control-Request-Method header,
380             // which is required for preflight requests
381             return request.getHeader(ACCESS_CONTROL_REQUEST_METHOD_HEADER) == null;
382         }
383         return false;
384     }
385 
386     private boolean isPreflightRequest(HttpServletRequest request)
387     {
388         String method = request.getMethod();
389         if (!"OPTIONS".equalsIgnoreCase(method))
390             return false;
391         if (request.getHeader(ACCESS_CONTROL_REQUEST_METHOD_HEADER) == null)
392             return false;
393         return true;
394     }
395 
396     private void handleSimpleResponse(HttpServletRequest request, HttpServletResponse response, String origin)
397     {
398         response.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN_HEADER, origin);
399         //W3C CORS spec http://www.w3.org/TR/cors/#resource-implementation
400         if (!anyOriginAllowed)
401             response.addHeader("Vary", ORIGIN_HEADER);
402         if (allowCredentials)
403             response.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS_HEADER, "true");
404         if (!exposedHeaders.isEmpty())
405             response.setHeader(ACCESS_CONTROL_EXPOSE_HEADERS_HEADER, commify(exposedHeaders));
406     }
407 
408     private void handlePreflightResponse(HttpServletRequest request, HttpServletResponse response, String origin)
409     {
410         boolean methodAllowed = isMethodAllowed(request);
411 
412         if (!methodAllowed)
413             return;
414         List<String> headersRequested = getAccessControlRequestHeaders(request);
415         boolean headersAllowed = areHeadersAllowed(headersRequested);
416         if (!headersAllowed)
417             return;
418         response.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN_HEADER, origin);
419         //W3C CORS spec http://www.w3.org/TR/cors/#resource-implementation
420         if (!anyOriginAllowed)
421             response.addHeader("Vary", ORIGIN_HEADER);
422         if (allowCredentials)
423             response.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS_HEADER, "true");
424         if (preflightMaxAge > 0)
425             response.setHeader(ACCESS_CONTROL_MAX_AGE_HEADER, String.valueOf(preflightMaxAge));
426         response.setHeader(ACCESS_CONTROL_ALLOW_METHODS_HEADER, commify(allowedMethods));
427         if (anyHeadersAllowed)
428             response.setHeader(ACCESS_CONTROL_ALLOW_HEADERS_HEADER, commify(headersRequested));
429         else
430             response.setHeader(ACCESS_CONTROL_ALLOW_HEADERS_HEADER, commify(allowedHeaders));
431     }
432 
433     private boolean isMethodAllowed(HttpServletRequest request)
434     {
435         String accessControlRequestMethod = request.getHeader(ACCESS_CONTROL_REQUEST_METHOD_HEADER);
436         LOG.debug("{} is {}", ACCESS_CONTROL_REQUEST_METHOD_HEADER, accessControlRequestMethod);
437         boolean result = false;
438         if (accessControlRequestMethod != null)
439             result = allowedMethods.contains(accessControlRequestMethod);
440         LOG.debug("Method {} is" + (result ? "" : " not") + " among allowed methods {}", accessControlRequestMethod, allowedMethods);
441         return result;
442     }
443 
444     private List<String> getAccessControlRequestHeaders(HttpServletRequest request)
445     {
446         String accessControlRequestHeaders = request.getHeader(ACCESS_CONTROL_REQUEST_HEADERS_HEADER);
447         LOG.debug("{} is {}", ACCESS_CONTROL_REQUEST_HEADERS_HEADER, accessControlRequestHeaders);
448         if (accessControlRequestHeaders == null)
449             return Collections.emptyList();
450 
451         List<String> requestedHeaders = new ArrayList<String>();
452         String[] headers = StringUtil.csvSplit(accessControlRequestHeaders);
453         for (String header : headers)
454         {
455             String h = header.trim();
456             if (h.length() > 0)
457                 requestedHeaders.add(h);
458         }
459         return requestedHeaders;
460     }
461 
462     private boolean areHeadersAllowed(List<String> requestedHeaders)
463     {
464         if (anyHeadersAllowed)
465         {
466             LOG.debug("Any header is allowed");
467             return true;
468         }
469 
470         boolean result = true;
471         for (String requestedHeader : requestedHeaders)
472         {
473             boolean headerAllowed = false;
474             for (String allowedHeader : allowedHeaders)
475             {
476                 if (requestedHeader.equalsIgnoreCase(allowedHeader.trim()))
477                 {
478                     headerAllowed = true;
479                     break;
480                 }
481             }
482             if (!headerAllowed)
483             {
484                 result = false;
485                 break;
486             }
487         }
488         LOG.debug("Headers [{}] are" + (result ? "" : " not") + " among allowed headers {}", requestedHeaders, allowedHeaders);
489         return result;
490     }
491 
492     private String commify(List<String> strings)
493     {
494         StringBuilder builder = new StringBuilder();
495         for (int i = 0; i < strings.size(); ++i)
496         {
497             if (i > 0) builder.append(",");
498             String string = strings.get(i);
499             builder.append(string);
500         }
501         return builder.toString();
502     }
503 
504     public void destroy()
505     {
506         anyOriginAllowed = false;
507         allowedOrigins.clear();
508         allowedMethods.clear();
509         allowedHeaders.clear();
510         preflightMaxAge = 0;
511         allowCredentials = false;
512     }
513 }