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