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