View Javadoc

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