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