View Javadoc

1   //
2   //  ========================================================================
3   //  Copyright (c) 1995-2013 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   * &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   */
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 }