001/* 002 * (C) Copyright 2018 Nuxeo (http://nuxeo.com/) and others. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 * 016 * Contributors: 017 * Florent Guillaume 018 */ 019package org.nuxeo.ecm.platform.web.common.requestcontroller.filter; 020 021import static com.google.common.net.HttpHeaders.ORIGIN; 022import static com.google.common.net.HttpHeaders.REFERER; 023import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN; 024import static javax.servlet.http.HttpServletResponse.SC_OK; 025import static org.apache.commons.lang3.StringUtils.isBlank; 026 027import java.io.IOException; 028import java.io.Serializable; 029import java.net.URI; 030import java.net.URISyntaxException; 031import java.security.SecureRandom; 032import java.util.ArrayList; 033import java.util.Arrays; 034import java.util.HashSet; 035import java.util.List; 036import java.util.Map; 037import java.util.Objects; 038import java.util.Random; 039import java.util.Set; 040 041import javax.servlet.Filter; 042import javax.servlet.FilterChain; 043import javax.servlet.FilterConfig; 044import javax.servlet.ServletException; 045import javax.servlet.ServletRequest; 046import javax.servlet.ServletResponse; 047import javax.servlet.http.HttpServletRequest; 048import javax.servlet.http.HttpServletRequestWrapper; 049import javax.servlet.http.HttpServletResponse; 050import javax.servlet.http.HttpSession; 051 052import org.apache.commons.lang3.RandomStringUtils; 053import org.apache.commons.lang3.StringUtils; 054import org.apache.commons.logging.Log; 055import org.apache.commons.logging.LogFactory; 056import org.apache.http.client.methods.HttpGet; 057import org.apache.http.client.methods.HttpHead; 058import org.apache.http.client.methods.HttpOptions; 059import org.apache.http.client.methods.HttpTrace; 060import org.nuxeo.ecm.platform.web.common.requestcontroller.service.RequestControllerManager; 061import org.nuxeo.ecm.platform.web.common.vh.VirtualHostHelper; 062import org.nuxeo.runtime.api.Framework; 063import org.nuxeo.runtime.services.config.ConfigurationService; 064 065import com.thetransactioncompany.cors.CORSConfiguration; 066import com.thetransactioncompany.cors.CORSFilter; 067import com.thetransactioncompany.cors.Origin; 068 069/** 070 * Nuxeo CORS and CSRF filter, returning CORS configuration and preventing CSRF attacks by rejecting dubious requests. 071 * 072 * @since 5.7.2 for CORS 073 * @since 10.1 for CSRF 074 */ 075public class NuxeoCorsCsrfFilter implements Filter { 076 077 private static final Log log = LogFactory.getLog(NuxeoCorsCsrfFilter.class); 078 079 public static final String GET = HttpGet.METHOD_NAME; 080 081 public static final String HEAD = HttpHead.METHOD_NAME; 082 083 public static final String OPTIONS = HttpOptions.METHOD_NAME; 084 085 public static final String TRACE = HttpTrace.METHOD_NAME; 086 087 // safe methods according to RFC 7231 4.2.1 088 protected static final Set<String> SAFE_METHODS = new HashSet<>(Arrays.asList(GET, HEAD, OPTIONS, TRACE)); 089 090 // RFC 6454 091 // 6.2 If the origin is not a scheme/host/port triple, then return the string null 092 // 7.3 Whenever a user agent issues an HTTP request from a "privacy-sensitive" context, 093 // the user agent MUST send the value "null" in the Origin header field. 094 public static final String ORIGIN_NULL = "null"; 095 096 // marker for privacy-sensitive origins 097 public static final URI PRIVACY_SENSITIVE = URI.create("privacy-sensitive:///"); 098 099 public static final List<String> SCHEMES_ALLOWED = Arrays.asList("moz-extension", "chrome-extension"); 100 101 /** 102 * Allows to disable strict CORS checks when a request has Origin: null. 103 * <p> 104 * This may happen for local files, or for a JavaScript-triggered redirect. Setting this to false may expose the 105 * application to CSRF problems from files locally hosted on the user's disk. 106 * 107 * @since 10.3 108 */ 109 public static final String ALLOW_NULL_ORIGIN_PROP = "nuxeo.cors.allowNullOrigin"; 110 111 /** @since 10.3 */ 112 public static final String ALLOW_NULL_ORIGIN_DEFAULT = "false"; 113 114 /** 115 * Configuration property (namespace) for CSRF tokens. 116 * 117 * @since 10.3 118 */ 119 public static final String CSRF_TOKEN_NS_PROP = "nuxeo.csrf.token"; 120 121 /** 122 * Allows enforcing the use of a CSRF token. Configuration property (under the {@value #CSRF_TOKEN_NS_PROP} 123 * namespace). 124 * 125 * @since 10.3 126 */ 127 public static final String CSRF_TOKEN_ENABLED_SUBPROP = "enabled"; 128 129 /** @since 10.3 */ 130 public static final String CSRF_TOKEN_ENABLED_DEFAULT = "false"; 131 132 /** 133 * Allows definition of endpoints for which no CSRF token check is done. Configuration <em>list</em> property (under 134 * the {@value #CSRF_TOKEN_NS_PROP} namespace). 135 * 136 * @since 10.3 137 */ 138 public static final String CSRF_TOKEN_SKIP_SUBPROP = "skip"; 139 140 /** 141 * Session attribute in which token is stored. 142 * 143 * @since 10.3 144 */ 145 public static final String CSRF_TOKEN_ATTRIBUTE = "NuxeoCSRFToken"; 146 147 /** 148 * Request header to pass a token, or fetch one. 149 * 150 * @since 10.3 151 */ 152 public static final String CSRF_TOKEN_HEADER = "CSRF-Token"; 153 154 /** 155 * Pseudo-value to fetch a token. 156 * 157 * @since 10.3 158 */ 159 public static final String CSRF_TOKEN_FETCH = "fetch"; 160 161 /** 162 * Pseudo-value to denote an invalid token. 163 * 164 * @since 10.3 165 */ 166 public static final String CSRF_TOKEN_INVALID = "invalid"; 167 168 /** 169 * Request parameter to pass a token. 170 * 171 * @since 10.3 172 */ 173 public static final String CSRF_TOKEN_PARAM = "csrf-token"; 174 175 protected static final Random RANDOM = new SecureRandom(); 176 177 protected boolean allowNullOrigin; 178 179 protected boolean csrfTokenEnabled; 180 181 protected List<String> csrfTokenSkipPaths; 182 183 @Override 184 public void init(FilterConfig filterConfig) { 185 ConfigurationService configurationService = Framework.getService(ConfigurationService.class); 186 allowNullOrigin = Boolean.parseBoolean( 187 configurationService.getProperty(ALLOW_NULL_ORIGIN_PROP, ALLOW_NULL_ORIGIN_DEFAULT)); 188 Map<String, Serializable> csrfTokenConfig = configurationService.getProperties(CSRF_TOKEN_NS_PROP); 189 csrfTokenEnabled = Boolean.parseBoolean(StringUtils.defaultString( 190 (String) csrfTokenConfig.get(CSRF_TOKEN_ENABLED_SUBPROP), CSRF_TOKEN_ENABLED_DEFAULT)); 191 csrfTokenSkipPaths = new ArrayList<>(); 192 Serializable skipPaths = csrfTokenConfig.get(CSRF_TOKEN_SKIP_SUBPROP); 193 if (skipPaths instanceof String[]) { 194 csrfTokenSkipPaths.addAll(Arrays.asList((String[]) skipPaths)); 195 } 196 } 197 198 @Override 199 public void destroy() { 200 // nothing to do 201 } 202 203 @Override 204 public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) 205 throws IOException, ServletException { 206 HttpServletRequest request = (HttpServletRequest) servletRequest; 207 HttpServletResponse response = (HttpServletResponse) servletResponse; 208 209 if (manageCSRFToken(request, response)) { 210 return; 211 } 212 213 RequestControllerManager service = Framework.getService(RequestControllerManager.class); 214 CORSFilter corsFilter = service.getCorsFilterForRequest(request); 215 CORSConfiguration corsConfig = corsFilter == null ? null : corsFilter.getConfiguration(); 216 String method = request.getMethod(); 217 URI sourceURI = getSourceURI(request); 218 URI targetURI = getTargetURI(request); 219 if (log.isDebugEnabled()) { 220 log.debug("Method: " + method + ", source: " + sourceURI + ", target: " + targetURI); 221 } 222 223 boolean allow; 224 if (isSafeMethod(method)) { 225 // safe method according to RFC 7231 4.2.1 226 log.debug("Safe method: allow"); 227 allow = true; 228 } else if (sourceAndTargetMatch(sourceURI, targetURI)) { 229 // source and target match, or not provided 230 log.debug("Source and target match: allow"); 231 if (targetURI == null) { 232 // misconfigured server or proxy headers 233 log.error("Cannot determine target URL for CSRF check"); 234 } 235 allow = true; 236 } else if (corsConfig == null) { 237 // source not known by CORS config: be safe and disallow 238 log.debug("URL not covered by CORS config: disallow cross-site request"); 239 allow = false; 240 } else if (!corsConfig.isAllowedOrigin(originFromURI(sourceURI))) { 241 // not in allowed CORS origins 242 log.debug("Origin not allowed by CORS config: disallow cross-site request"); 243 allow = false; 244 } else if (!corsConfig.isSupportedMethod(method)) { 245 // not in allowed CORS methods 246 log.debug("Method not allowed by CORS config: disallow cross-site request"); 247 allow = false; 248 } else { 249 log.debug("Origin and method allowed by CORS config: allow cross-site request"); 250 allow = true; 251 } 252 253 if (allow) { 254 if (corsFilter == null) { 255 chain.doFilter(request, response); 256 } else { 257 request = maybeIgnoreWhitelistedOrigin(request); 258 corsFilter.doFilter(request, response, chain); 259 } 260 return; 261 } 262 263 // disallowed cross-site request 264 String message = "CSRF check failure"; 265 log.warn(message + ": source: " + sourceURI + " does not match target: " + targetURI 266 + " and not allowed by CORS config"); 267 response.sendError(HttpServletResponse.SC_FORBIDDEN, message); 268 } 269 270 /** 271 * Check safe method according to RFC 7231 4.2.1. 272 */ 273 protected boolean isSafeMethod(String method) { 274 return SAFE_METHODS.contains(method); 275 } 276 277 /** 278 * Manages the CSRF token. 279 * <p> 280 * This method may return a response with token fetch information or with an error if needed, in which case it will 281 * return {@code true}. 282 * 283 * @return {@code true} if the caller doesn't need to do more work (a response has been sent) 284 * @since 10.3 285 */ 286 protected boolean manageCSRFToken(HttpServletRequest request, HttpServletResponse response) throws IOException { 287 if (!csrfTokenEnabled) { 288 log.debug("No CSRF token check configured"); 289 return false; // no check to do 290 } 291 292 String method = request.getMethod(); 293 String path = request.getServletPath(); 294 if (path == null) { 295 path = ""; 296 } 297 String pathInfo = request.getPathInfo(); 298 if (pathInfo != null) { 299 path += pathInfo; 300 } 301 String requestToken = request.getHeader(CSRF_TOKEN_HEADER); 302 303 // token fetch request 304 if (GET.equals(method) && path.isEmpty() && CSRF_TOKEN_FETCH.equals(requestToken)) { 305 HttpSession session = request.getSession(); // create if needed 306 String token = (String) session.getAttribute(CSRF_TOKEN_ATTRIBUTE); 307 if (token == null) { 308 token = generateNewToken(); 309 session.setAttribute(CSRF_TOKEN_ATTRIBUTE, token); 310 } 311 log.debug("Returning CSRF token fetch"); 312 response.setHeader(CSRF_TOKEN_HEADER, token); 313 response.setStatus(SC_OK); 314 return true; 315 316 } 317 318 // do we need to check the token? 319 if (isSafeMethod(method)) { 320 log.debug("No CSRF token check on safe method"); 321 return false; 322 } 323 324 // is the endpoint specially configured to skip the token check? 325 if (csrfTokenSkipPaths.contains(path)) { 326 log.debug("No CSRF token check on configured endpoint"); 327 return false; 328 } 329 330 // check the token 331 HttpSession session = request.getSession(false); 332 String token; 333 if (session == null || (token = (String) session.getAttribute(CSRF_TOKEN_ATTRIBUTE)) == null) { 334 log.debug("Error, no session or no CSRF token in session"); 335 String message = "CSRF check failure"; 336 log.warn(message + ": invalid token"); 337 response.setHeader(CSRF_TOKEN_HEADER, CSRF_TOKEN_INVALID); 338 response.sendError(SC_FORBIDDEN, message); 339 return true; 340 } 341 if (StringUtils.isEmpty(requestToken)) { 342 // allow request parameter to contain the token too 343 requestToken = request.getParameter(CSRF_TOKEN_PARAM); 344 } 345 if (!token.equals(requestToken)) { 346 log.debug("Error, CSRF token does not match"); 347 String message = "CSRF check failure"; 348 log.warn(message + ": invalid token"); 349 response.setHeader(CSRF_TOKEN_HEADER, CSRF_TOKEN_INVALID); 350 response.sendError(SC_FORBIDDEN, message); 351 return true; 352 } 353 354 // token is ok, proceed 355 log.debug("CSRF token matches"); 356 return false; 357 } 358 359 protected String generateNewToken() { 360 return RandomStringUtils.random(40, 0, 0, true, true, null, RANDOM); 361 } 362 363 /** 364 * Gets the source URI: the URI of the page from which the request is actually coming. 365 * <p> 366 * {@code null} is returned is there is no header. 367 * <p> 368 * {@link #PRIVACY_SENSITIVE} is returned is there is a null origin (RFC 6454 7.3, "privacy-sensitive" context) 369 * unless configured to be ignored. 370 */ 371 public URI getSourceURI(HttpServletRequest request) { 372 String source = request.getHeader(ORIGIN); 373 if (isBlank(source)) { 374 source = request.getHeader(REFERER); 375 } 376 if (isBlank(source)) { 377 return null; 378 } 379 source = source.trim(); 380 if (ORIGIN_NULL.equals(source)) { 381 return allowNullOrigin ? null : PRIVACY_SENSITIVE; 382 } 383 if (source.contains(" ")) { 384 // RFC 6454 7.1 origin-list 385 // keep only the first origin to simplify the logic (nobody sends two origins anyway) 386 source = source.substring(0, source.indexOf(' ')); 387 } 388 try { 389 return new URI(source); // NOSONAR (URI is not opened as a stream) 390 } catch (URISyntaxException e) { 391 return null; 392 } 393 } 394 395 /** Gets the target URI: the URI to which the browser is connecting. */ 396 public URI getTargetURI(HttpServletRequest request) { 397 String baseURL = VirtualHostHelper.getServerURL(request, false); 398 if (baseURL == null) { 399 return null; 400 } 401 try { 402 return new URI(baseURL); // NOSONAR (URI is not opened as a stream) 403 } catch (URISyntaxException e) { 404 return null; 405 } 406 } 407 408 public boolean sourceAndTargetMatch(URI sourceURI, URI targetURI) { 409 if (sourceURI == null || targetURI == null) { 410 return true; 411 } 412 if (isWhitelistedScheme(sourceURI)) { 413 return true; 414 } 415 return Objects.equals(sourceURI.getScheme(), targetURI.getScheme()) // 416 && Objects.equals(sourceURI.getHost(), targetURI.getHost()) // 417 && sourceURI.getPort() == targetURI.getPort(); 418 } 419 420 /** 421 * Gets an Origin from a URI. Strips the path and query (which may be present in Referer headers). 422 */ 423 protected Origin originFromURI(URI uri) { 424 // remove path, query and fragment 425 try { 426 uri = new URI(uri.getScheme(), null, uri.getHost(), uri.getPort(), null, null, null); 427 } catch (URISyntaxException e) { 428 // keep passed-in URI 429 } 430 return new Origin(uri.toString()); 431 } 432 433 protected HttpServletRequest maybeIgnoreWhitelistedOrigin(HttpServletRequest request) { 434 String origin = request.getHeader(ORIGIN); 435 if (origin == null) { 436 return request; 437 } 438 URI uri; 439 try { 440 uri = new URI(origin); // NOSONAR (URI is not opened as a stream) 441 } catch (URISyntaxException e) { 442 return request; 443 } 444 if (!isWhitelistedScheme(uri)) { 445 return request; 446 } 447 // wrap request to pretend that the Origin is absent 448 return new IgnoredOriginRequestWrapper(request); 449 } 450 451 protected boolean isWhitelistedScheme(URI uri) { 452 return SCHEMES_ALLOWED.contains(uri.getScheme()); 453 } 454 455 /** 456 * Wrapper for the request to hide the Origin header. 457 * 458 * @since 10.2 459 */ 460 public static class IgnoredOriginRequestWrapper extends HttpServletRequestWrapper { 461 462 public IgnoredOriginRequestWrapper(HttpServletRequest request) { 463 super(request); 464 } 465 466 @Override 467 public String getHeader(String name) { 468 if (ORIGIN.equalsIgnoreCase(name)) { 469 return null; 470 } 471 return super.getHeader(name); 472 } 473 } 474 475}