View Javadoc

1   package com.atlassian.plugins.rest.common.security.jersey;
2   
3   import com.atlassian.http.mime.BrowserUtils;
4   import com.atlassian.http.mime.UserAgentUtil.BrowserFamily;
5   import com.atlassian.http.mime.UserAgentUtilImpl;
6   import com.atlassian.http.url.SameOrigin;
7   import com.atlassian.plugin.tracker.PluginModuleTracker;
8   import com.atlassian.plugins.rest.common.security.CorsHeaders;
9   import com.atlassian.plugins.rest.common.security.XsrfCheckFailedException;
10  import com.atlassian.plugins.rest.common.security.descriptor.CorsDefaults;
11  import com.atlassian.plugins.rest.common.security.descriptor.CorsDefaultsModuleDescriptor;
12  import com.atlassian.sal.api.web.context.HttpContext;
13  import com.atlassian.sal.api.xsrf.XsrfRequestValidator;
14  import com.google.common.annotations.VisibleForTesting;
15  import com.google.common.base.Predicate;
16  import com.google.common.collect.ImmutableSet;
17  import com.google.common.collect.Iterables;
18  import com.sun.jersey.spi.container.ContainerRequest;
19  import com.sun.jersey.spi.container.ContainerRequestFilter;
20  import com.sun.jersey.spi.container.ContainerResponseFilter;
21  import com.sun.jersey.spi.container.ResourceFilter;
22  import org.apache.commons.lang.StringUtils;
23  import org.slf4j.Logger;
24  import org.slf4j.LoggerFactory;
25  
26  import javax.servlet.http.HttpServletRequest;
27  import javax.ws.rs.core.MediaType;
28  import javax.ws.rs.core.Response;
29  import java.net.MalformedURLException;
30  import java.net.URI;
31  import java.net.URISyntaxException;
32  import java.util.Locale;
33  
34  /**
35   * Rejects requests that do not satisfy XSRF checks.
36   *
37   * A request is rejected if it requires XSRF protection, but does not include the
38   * no-check header or a valid XSRF protection token.
39   *
40   * Also protects browsers against XSRF attacks where the origin of a request would not
41   * otherwise be permitted by the same origin policy or CORS.
42   *
43   * @since 2.4
44   */
45  public class XsrfResourceFilter implements ResourceFilter, ContainerRequestFilter {
46      public static final String TOKEN_HEADER = "X-Atlassian-Token";
47      public static final String NO_CHECK = "no-check";
48  
49      private static final ImmutableSet<String> XSRFABLE_TYPES = ImmutableSet.of(
50              MediaType.APPLICATION_FORM_URLENCODED,
51              MediaType.MULTIPART_FORM_DATA,
52              MediaType.TEXT_PLAIN
53      );
54      private static final ImmutableSet<String> BROWSER_EXTENSION_ORIGINS = ImmutableSet.of(
55              "chrome-extension",
56              "safari-extension"
57      );
58      private static final Logger log = LoggerFactory.getLogger(XsrfResourceFilter.class);
59  
60      private HttpContext httpContext;
61      private XsrfRequestValidator xsrfRequestValidator;
62      private PluginModuleTracker<CorsDefaults, CorsDefaultsModuleDescriptor> pluginModuleTracker;
63      private Response.Status failureStatus = Response.Status.FORBIDDEN;
64  
65      public void setHttpContext(final HttpContext httpContext) {
66          this.httpContext = httpContext;
67      }
68  
69      public void setXsrfRequestValidator(final XsrfRequestValidator xsrfRequestValidator) {
70          this.xsrfRequestValidator = xsrfRequestValidator;
71      }
72  
73      public void setPluginModuleTracker(final PluginModuleTracker<CorsDefaults, CorsDefaultsModuleDescriptor> pluginModuleTracker) {
74          this.pluginModuleTracker = pluginModuleTracker;
75      }
76  
77      public void setFailureStatus(final Response.Status failureStatus) {
78          if (!(failureStatus == Response.Status.FORBIDDEN ||
79                  (failureStatus == Response.Status.NOT_FOUND))) {
80              throw new IllegalArgumentException(
81                      "Only FORBIDDEN and NOT_FOUND status are valid arguments.");
82          }
83          this.failureStatus = failureStatus;
84      }
85  
86      public ContainerRequest filter(final ContainerRequest request) {
87          if (passesAllXsrfChecks(request)) {
88              return request;
89          }
90  
91          throw new XsrfCheckFailedException(failureStatus);
92      }
93  
94      private boolean passesAllXsrfChecks(final ContainerRequest request) {
95          if (isPostRequest(request) && isLikelyToBeFromBrowser(request) && !passesAdditionalBrowserChecks(request)) {
96              return false;
97          }
98  
99          if (isXsrfable(request)) {
100             final boolean passes = passesStandardXsrfChecks(
101                     getRequestOrNull(httpContext)) || hasDeprecatedHeaderValue(request);
102             if (passes) {
103                 return true;
104             }
105             log.warn(String.format(
106                     "XSRF checks failed for request: %s , origin: %s , referrer: %s",
107                     StringUtils.substringBefore(request.getRequestUri().toString(), "?"),
108                     request.getHeaderValue(CorsHeaders.ORIGIN.value()),
109                     getSanitisedReferrer(request)
110             ));
111             return false;
112         }
113 
114         return true;
115     }
116 
117     private boolean passesStandardXsrfChecks(final HttpServletRequest httpServletRequest) {
118         if (httpServletRequest == null) {
119             return false;
120         }
121         return xsrfRequestValidator.validateRequestPassesXsrfChecks(httpServletRequest);
122     }
123 
124     /**
125      * Returns true if the provided origin is from a browser extension.
126      *
127      * @param origin the origin to check.
128      * @return true if the provided origin is from a browser extension,
129      * otherwise returns false.
130      */
131     boolean isOriginABrowserExtension(String origin) {
132         if (StringUtils.isEmpty(origin)) {
133             return false;
134         }
135         try {
136             final URI originUri = new URI(origin);
137             return BROWSER_EXTENSION_ORIGINS.contains(originUri.getScheme()) &&
138                     !originUri.isOpaque();
139         } catch (URISyntaxException e) {
140             return false;
141         }
142     }
143 
144     /**
145      * Due to bugs in some browsers, it is possible for non-simple cross-domain HTTP requests
146      * to be issued *without* the normal CORS preflight request being triggered. This meant
147      * that both our CORS restrictions and our XSRF protections could be bypassed.
148      */
149     @VisibleForTesting
150     protected boolean passesAdditionalBrowserChecks(final ContainerRequest request) {
151         final String origin = request.getHeaderValue(CorsHeaders.ORIGIN.value());
152         final String referrer = getSanitisedReferrer(request);
153         final URI uri = request.getRequestUri();
154 
155         if (isSameOrigin(referrer, uri)) {
156             return true;
157         }
158 
159         if (isSameOrigin(origin, uri)) {
160             return true;
161         }
162         if (isOriginABrowserExtension(origin)) {
163             return true;
164         }
165         final boolean requestContainsCredentials = containsCredentials(request);
166         final boolean requestAllowedViaCors = isAllowedViaCors(origin, requestContainsCredentials);
167 
168         if (requestAllowedViaCors) {
169             return true;
170         }
171 
172         log.warn(String.format("Additional XSRF checks failed for request: %s , origin: %s , referrer: %s , credentials in request: %s , allowed via CORS: %s",
173                 StringUtils.substringBefore(uri.toString(), "?"),
174                 origin,
175                 referrer,
176                 requestContainsCredentials,
177                 requestAllowedViaCors));
178 
179         return false;
180     }
181 
182     boolean isXsrfable(ContainerRequest request) {
183         String method = request.getMethod();
184         return method.equals("GET") || (
185                 method.equals("POST") && (request.getMediaType() == null
186                         || XSRFABLE_TYPES.contains(mediaTypeToString(request.getMediaType()))));
187     }
188 
189     private boolean hasDeprecatedHeaderValue(final ContainerRequest request) {
190         final String tokenHeader = request.getHeaderValue(TOKEN_HEADER);
191 
192         if (tokenHeader == null) {
193             return false;
194         }
195 
196         final String normalisedTokenHeader = tokenHeader.toLowerCase(Locale.ENGLISH);
197 
198         if (normalisedTokenHeader.equals("nocheck")) {
199             log.warn("Use of the 'nocheck' value for {} " +
200                     "has been deprecated since rest 3.0.0. Please use a value of " +
201                     "'no-check' instead.", TOKEN_HEADER);
202             return true;
203         }
204 
205         return false;
206     }
207 
208     private boolean isSameOrigin(final String uri, final URI origin) {
209         try {
210             return StringUtils.isNotEmpty(uri) && SameOrigin.isSameOrigin(new URI(uri), origin);
211         } catch (MalformedURLException e) {
212             return false;
213         } catch (URISyntaxException e) {
214             return false;
215         } catch (IllegalArgumentException e) {
216             return false;
217         }
218     }
219 
220     private boolean isAllowedViaCors(final String originUri, final boolean withCredentials) {
221         if (originUri == null) {
222             return false;
223         }
224 
225         return Iterables.any(pluginModuleTracker.getModules(), new Predicate<CorsDefaults>() {
226             public boolean apply(CorsDefaults delegate) {
227                 if (!delegate.allowsOrigin(originUri)) {
228                     return false;
229                 } else if (withCredentials && !delegate.allowsCredentials(originUri)) {
230                     return false;
231                 }
232 
233                 return true;
234             }
235         });
236     }
237 
238     public ContainerRequestFilter getRequestFilter() {
239         return this;
240     }
241 
242     public ContainerResponseFilter getResponseFilter() {
243         return null;
244     }
245 
246     private static boolean containsCredentials(final ContainerRequest request) {
247         return containsCookies(request) || containsHttpAuthHeader(request);
248     }
249 
250     private static boolean containsCookies(final ContainerRequest request) {
251         return !request.getCookies().isEmpty();
252     }
253 
254     private static boolean containsHttpAuthHeader(final ContainerRequest request) {
255         return StringUtils.isNotEmpty(request.getHeaderValue("Authorization"));
256     }
257 
258     static boolean isPostRequest(final ContainerRequest request) {
259         return request.getMethod().equals("POST");
260     }
261 
262     boolean isLikelyToBeFromBrowser(final ContainerRequest request) {
263         final String userAgent = request.getHeaderValue("User-Agent");
264         final BrowserFamily browserFamily = new UserAgentUtilImpl().getBrowserFamily(userAgent);
265         if ((passesStandardXsrfChecks(getRequestOrNull(httpContext)) || hasDeprecatedHeaderValue(request)) && BrowserUtils.isIE(userAgent)) {
266             return false;
267         }
268         return !browserFamily.equals(BrowserFamily.UKNOWN);
269     }
270 
271     private static HttpServletRequest getRequestOrNull(final HttpContext httpContext) {
272         return (httpContext == null) ? null : httpContext.getRequest();
273     }
274 
275     private static String mediaTypeToString(MediaType mediaType) {
276         return mediaType.getType().toLowerCase(Locale.ENGLISH) + "/" + mediaType.getSubtype().toLowerCase(Locale.ENGLISH);
277     }
278 
279     private static String getSanitisedReferrer(final ContainerRequest request) {
280         return StringUtils.substringBefore(request.getHeaderValue("Referer"), "?");
281     }
282 
283 }