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
36
37
38
39
40
41
42
43
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
126
127
128
129
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
146
147
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 }