View Javadoc

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