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
39
40
41
42
43
44
45
46
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
164
165
166
167
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
184
185
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 }