View Javadoc

1   /*
2    * Copyright (c) 2009-2009 Mort Bay Consulting Pty. Ltd.
3    *
4    * All rights reserved. This program and the accompanying materials
5    * are made available under the terms of the Eclipse Public License v1.0
6    * and Apache License v2.0 which accompanies this distribution.
7    * The Eclipse Public License is available at
8    * http://www.eclipse.org/legal/epl-v10.html
9    * The Apache License v2.0 is available at
10   * http://www.opensource.org/licenses/apache2.0.php
11   *
12   * You may elect to redistribute this code under either of these licenses.
13   */
14  
15  package org.eclipse.jetty.servlets;
16  
17  import java.io.IOException;
18  import java.util.ArrayList;
19  import java.util.Arrays;
20  import java.util.Enumeration;
21  import java.util.List;
22  import java.util.regex.Matcher;
23  import java.util.regex.Pattern;
24  import javax.servlet.Filter;
25  import javax.servlet.FilterChain;
26  import javax.servlet.FilterConfig;
27  import javax.servlet.ServletException;
28  import javax.servlet.ServletRequest;
29  import javax.servlet.ServletResponse;
30  import javax.servlet.http.HttpServletRequest;
31  import javax.servlet.http.HttpServletResponse;
32  
33  import org.eclipse.jetty.util.log.Log;
34  import org.eclipse.jetty.util.log.Logger;
35  
36  /**
37   * <p>Implementation of the
38   * <a href="http://www.w3.org/TR/cors/">cross-origin resource sharing</a>.</p>
39   * <p>A typical example is to use this filter to allow cross-domain
40   * <a href="http://cometd.org">cometd</a> communication using the standard
41   * long polling transport instead of the JSONP transport (that is less
42   * efficient and less reactive to failures).</p>
43   * <p>This filter allows the following configuration parameters:
44   * <ul>
45   * <li><b>allowedOrigins</b>, a comma separated list of origins that are
46   * allowed to access the resources. Default value is <b>*</b>, meaning all
47   * origins.<br />
48   * If an allowed origin contains one or more * characters (for example
49   * http://*.domain.com), then "*" characters are converted to ".*", "."
50   * characters are escaped to "\." and the resulting allowed origin
51   * interpreted as a regular expression.<br />
52   * Allowed origins can therefore be more complex expressions such as
53   * https?://*.domain.[a-z]{3} that matches http or https, multiple subdomains
54   * and any 3 letter top-level domain (.com, .net, .org, etc.).</li>
55   * <li><b>allowedMethods</b>, a comma separated list of HTTP methods that
56   * are allowed to be used when accessing the resources. Default value is
57   * <b>GET,POST</b></li>
58   * <li><b>allowedHeaders</b>, a comma separated list of HTTP headers that
59   * are allowed to be specified when accessing the resources. Default value
60   * is <b>X-Requested-With</b></li>
61   * <li><b>preflightMaxAge</b>, the number of seconds that preflight requests
62   * can be cached by the client. Default value is <b>1800</b> seconds, or 30
63   * minutes</li>
64   * <li><b>allowCredentials</b>, a boolean indicating if the resource allows
65   * requests with credentials. Default value is <b>false</b></li>
66   * </ul></p>
67   * <p>A typical configuration could be:
68   * <pre>
69   * &lt;web-app ...&gt;
70   *     ...
71   *     &lt;filter&gt;
72   *         &lt;filter-name&gt;cross-origin&lt;/filter-name&gt;
73   *         &lt;filter-class&gt;org.eclipse.jetty.servlets.CrossOriginFilter&lt;/filter-class&gt;
74   *     &lt;/filter&gt;
75   *     &lt;filter-mapping&gt;
76   *         &lt;filter-name&gt;cross-origin&lt;/filter-name&gt;
77   *         &lt;url-pattern&gt;/cometd/*&lt;/url-pattern&gt;
78   *     &lt;/filter-mapping&gt;
79   *     ...
80   * &lt;/web-app&gt;
81   * </pre></p>
82   *
83   * @version $Revision$ $Date$
84   */
85  public class CrossOriginFilter implements Filter
86  {
87      private static final Logger LOG = Log.getLogger(CrossOriginFilter.class);
88  
89      // Request headers
90      private static final String ORIGIN_HEADER = "Origin";
91      public static final String ACCESS_CONTROL_REQUEST_METHOD_HEADER = "Access-Control-Request-Method";
92      public static final String ACCESS_CONTROL_REQUEST_HEADERS_HEADER = "Access-Control-Request-Headers";
93      // Response headers
94      public static final String ACCESS_CONTROL_ALLOW_ORIGIN_HEADER = "Access-Control-Allow-Origin";
95      public static final String ACCESS_CONTROL_ALLOW_METHODS_HEADER = "Access-Control-Allow-Methods";
96      public static final String ACCESS_CONTROL_ALLOW_HEADERS_HEADER = "Access-Control-Allow-Headers";
97      public static final String ACCESS_CONTROL_MAX_AGE_HEADER = "Access-Control-Max-Age";
98      public static final String ACCESS_CONTROL_ALLOW_CREDENTIALS_HEADER = "Access-Control-Allow-Credentials";
99      // Implementation constants
100     public static final String ALLOWED_ORIGINS_PARAM = "allowedOrigins";
101     public static final String ALLOWED_METHODS_PARAM = "allowedMethods";
102     public static final String ALLOWED_HEADERS_PARAM = "allowedHeaders";
103     public static final String PREFLIGHT_MAX_AGE_PARAM = "preflightMaxAge";
104     public static final String ALLOW_CREDENTIALS_PARAM = "allowCredentials";
105     private static final String ANY_ORIGIN = "*";
106     private static final List<String> SIMPLE_HTTP_METHODS = Arrays.asList("GET", "POST", "HEAD");
107 
108     private boolean anyOriginAllowed;
109     private List<String> allowedOrigins = new ArrayList<String>();
110     private List<String> allowedMethods = new ArrayList<String>();
111     private List<String> allowedHeaders = new ArrayList<String>();
112     private int preflightMaxAge = 0;
113     private boolean allowCredentials;
114 
115     public void init(FilterConfig config) throws ServletException
116     {
117         String allowedOriginsConfig = config.getInitParameter(ALLOWED_ORIGINS_PARAM);
118         if (allowedOriginsConfig == null)
119             allowedOriginsConfig = "*";
120         String[] allowedOrigins = allowedOriginsConfig.split(",");
121         for (String allowedOrigin : allowedOrigins)
122         {
123             allowedOrigin = allowedOrigin.trim();
124             if (allowedOrigin.length() > 0)
125             {
126                 if (ANY_ORIGIN.equals(allowedOrigin))
127                 {
128                     anyOriginAllowed = true;
129                     this.allowedOrigins.clear();
130                     break;
131                 }
132                 else
133                 {
134                     this.allowedOrigins.add(allowedOrigin);
135                 }
136             }
137         }
138 
139         String allowedMethodsConfig = config.getInitParameter(ALLOWED_METHODS_PARAM);
140         if (allowedMethodsConfig == null)
141             allowedMethodsConfig = "GET,POST,HEAD";
142         allowedMethods.addAll(Arrays.asList(allowedMethodsConfig.split(",")));
143 
144         String allowedHeadersConfig = config.getInitParameter(ALLOWED_HEADERS_PARAM);
145         if (allowedHeadersConfig == null)
146             allowedHeadersConfig = "X-Requested-With,Content-Type,Accept,Origin";
147         allowedHeaders.addAll(Arrays.asList(allowedHeadersConfig.split(",")));
148 
149         String preflightMaxAgeConfig = config.getInitParameter(PREFLIGHT_MAX_AGE_PARAM);
150         if (preflightMaxAgeConfig == null)
151             preflightMaxAgeConfig = "1800"; // Default is 30 minutes
152         try
153         {
154             preflightMaxAge = Integer.parseInt(preflightMaxAgeConfig);
155         }
156         catch (NumberFormatException x)
157         {
158             LOG.info("Cross-origin filter, could not parse '{}' parameter as integer: {}", PREFLIGHT_MAX_AGE_PARAM, preflightMaxAgeConfig);
159         }
160 
161         String allowedCredentialsConfig = config.getInitParameter(ALLOW_CREDENTIALS_PARAM);
162         if (allowedCredentialsConfig == null)
163             allowedCredentialsConfig = "true";
164         allowCredentials = Boolean.parseBoolean(allowedCredentialsConfig);
165 
166         if (LOG.isDebugEnabled())
167         {
168             LOG.debug("Cross-origin filter configuration: " +
169                     ALLOWED_ORIGINS_PARAM + " = " + allowedOriginsConfig + ", " +
170                     ALLOWED_METHODS_PARAM + " = " + allowedMethodsConfig + ", " +
171                     ALLOWED_HEADERS_PARAM + " = " + allowedHeadersConfig + ", " +
172                     PREFLIGHT_MAX_AGE_PARAM + " = " + preflightMaxAgeConfig + ", " +
173                     ALLOW_CREDENTIALS_PARAM + " = " + allowedCredentialsConfig);
174         }
175     }
176 
177     public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException
178     {
179         handle((HttpServletRequest)request, (HttpServletResponse)response, chain);
180     }
181 
182     private void handle(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException
183     {
184         String origin = request.getHeader(ORIGIN_HEADER);
185         // Is it a cross origin request ?
186         if (origin != null && isEnabled(request))
187         {
188             if (originMatches(origin))
189             {
190                 if (isSimpleRequest(request))
191                 {
192                     LOG.debug("Cross-origin request to {} is a simple cross-origin request", request.getRequestURI());
193                     handleSimpleResponse(request, response, origin);
194                 }
195                 else if (isPreflightRequest(request))
196                 {
197                     LOG.debug("Cross-origin request to {} is a preflight cross-origin request", request.getRequestURI());
198                     handlePreflightResponse(request, response, origin);
199                 }
200                 else
201                 {
202                     LOG.debug("Cross-origin request to {} is a non-simple cross-origin request", request.getRequestURI());
203                     handleSimpleResponse(request, response, origin);
204                 }
205             }
206             else
207             {
208                 LOG.debug("Cross-origin request to " + request.getRequestURI() + " with origin " + origin + " does not match allowed origins " + allowedOrigins);
209             }
210         }
211 
212         chain.doFilter(request, response);
213     }
214 
215     protected boolean isEnabled(HttpServletRequest request)
216     {
217         // WebSocket clients such as Chrome 5 implement a version of the WebSocket
218         // protocol that does not accept extra response headers on the upgrade response
219         for (Enumeration connections = request.getHeaders("Connection"); connections.hasMoreElements();)
220         {
221             String connection = (String)connections.nextElement();
222             if ("Upgrade".equalsIgnoreCase(connection))
223             {
224                 for (Enumeration upgrades = request.getHeaders("Upgrade"); upgrades.hasMoreElements();)
225                 {
226                     String upgrade = (String)upgrades.nextElement();
227                     if ("WebSocket".equalsIgnoreCase(upgrade))
228                         return false;
229                 }
230             }
231         }
232         return true;
233     }
234 
235     private boolean originMatches(String originList)
236     {
237         if (anyOriginAllowed)
238             return true;
239 
240         if (originList.trim().length() == 0)
241             return false;
242 
243         String[] origins = originList.split(" ");
244         for (String origin : origins)
245         {
246             if (origin.trim().length() == 0)
247                 continue;
248 
249             for (String allowedOrigin : allowedOrigins)
250             {
251                 if (allowedOrigin.contains("*"))
252                 {
253                     Matcher matcher = createMatcher(origin,allowedOrigin);
254                     if (matcher.matches())
255                         return true;
256                 }
257                 else if (allowedOrigin.equals(origin))
258                 {
259                     return true;
260                 }
261             }
262         }
263         return false;
264     }
265 
266     private Matcher createMatcher(String origin, String allowedOrigin)
267     {
268         String regex = parseAllowedWildcardOriginToRegex(allowedOrigin);
269         Pattern pattern = Pattern.compile(regex);
270         return pattern.matcher(origin);
271     }
272 
273     private String parseAllowedWildcardOriginToRegex(String allowedOrigin)
274     {
275         String regex = allowedOrigin.replace(".","\\.");
276         return regex.replace("*",".*"); // we want to be greedy here to match multiple subdomains, thus we use .*
277     }
278 
279     private boolean isSimpleRequest(HttpServletRequest request)
280     {
281         String method = request.getMethod();
282         if (SIMPLE_HTTP_METHODS.contains(method))
283         {
284             // TODO: implement better detection of simple headers
285             // The specification says that for a request to be simple, custom request headers must be simple.
286             // Here for simplicity I just check if there is a Access-Control-Request-Method header,
287             // which is required for preflight requests
288             return request.getHeader(ACCESS_CONTROL_REQUEST_METHOD_HEADER) == null;
289         }
290         return false;
291     }
292 
293     private boolean isPreflightRequest(HttpServletRequest request)
294     {
295         String method = request.getMethod();
296         if (!"OPTIONS".equalsIgnoreCase(method))
297             return false;
298         if (request.getHeader(ACCESS_CONTROL_REQUEST_METHOD_HEADER) == null)
299             return false;
300         return true;
301     }
302 
303     private void handleSimpleResponse(HttpServletRequest request, HttpServletResponse response, String origin)
304     {
305         response.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN_HEADER, origin);
306         if (allowCredentials)
307             response.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS_HEADER, "true");
308     }
309 
310     private void handlePreflightResponse(HttpServletRequest request, HttpServletResponse response, String origin)
311     {
312         boolean methodAllowed = isMethodAllowed(request);
313         if (!methodAllowed)
314             return;
315         boolean headersAllowed = areHeadersAllowed(request);
316         if (!headersAllowed)
317             return;
318         response.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN_HEADER, origin);
319         if (allowCredentials)
320             response.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS_HEADER, "true");
321         if (preflightMaxAge > 0)
322             response.setHeader(ACCESS_CONTROL_MAX_AGE_HEADER, String.valueOf(preflightMaxAge));
323         response.setHeader(ACCESS_CONTROL_ALLOW_METHODS_HEADER, commify(allowedMethods));
324         response.setHeader(ACCESS_CONTROL_ALLOW_HEADERS_HEADER, commify(allowedHeaders));
325     }
326 
327     private boolean isMethodAllowed(HttpServletRequest request)
328     {
329         String accessControlRequestMethod = request.getHeader(ACCESS_CONTROL_REQUEST_METHOD_HEADER);
330         LOG.debug("{} is {}", ACCESS_CONTROL_REQUEST_METHOD_HEADER, accessControlRequestMethod);
331         boolean result = false;
332         if (accessControlRequestMethod != null)
333             result = allowedMethods.contains(accessControlRequestMethod);
334         LOG.debug("Method {} is" + (result ? "" : " not") + " among allowed methods {}", accessControlRequestMethod, allowedMethods);
335         return result;
336     }
337 
338     private boolean areHeadersAllowed(HttpServletRequest request)
339     {
340         String accessControlRequestHeaders = request.getHeader(ACCESS_CONTROL_REQUEST_HEADERS_HEADER);
341         LOG.debug("{} is {}", ACCESS_CONTROL_REQUEST_HEADERS_HEADER, accessControlRequestHeaders);
342         boolean result = true;
343         if (accessControlRequestHeaders != null)
344         {
345             String[] headers = accessControlRequestHeaders.split(",");
346             for (String header : headers)
347             {
348                 boolean headerAllowed = false;
349                 for (String allowedHeader : allowedHeaders)
350                 {
351                     if (header.trim().equalsIgnoreCase(allowedHeader.trim()))
352                     {
353                         headerAllowed = true;
354                         break;
355                     }
356                 }
357                 if (!headerAllowed)
358                 {
359                     result = false;
360                     break;
361                 }
362             }
363         }
364         LOG.debug("Headers [{}] are" + (result ? "" : " not") + " among allowed headers {}", accessControlRequestHeaders, allowedHeaders);
365         return result;
366     }
367 
368     private String commify(List<String> strings)
369     {
370         StringBuilder builder = new StringBuilder();
371         for (int i = 0; i < strings.size(); ++i)
372         {
373             if (i > 0) builder.append(",");
374             String string = strings.get(i);
375             builder.append(string);
376         }
377         return builder.toString();
378     }
379 
380     public void destroy()
381     {
382         anyOriginAllowed = false;
383         allowedOrigins.clear();
384         allowedMethods.clear();
385         allowedHeaders.clear();
386         preflightMaxAge = 0;
387         allowCredentials = false;
388     }
389 }