欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

【Spring Security系列】跨域请求伪造的防护

程序员文章站 2022-05-05 16:35:56
...

CSRF的全称是(Cross Site Request Forgery),可译为跨域请求伪造,是一种利用用户带登录 态的cookie迚行安全操作的攻击方式。CSRF实际上并不难防,但常常被系统开发者忽略,从而埋下巨 大的安全隐患。

1.CSRF的攻击过程

为了试图说明CSRF的攻击过程,现在思考下面这个场景。

假如有一个博客网站,为了激励用户写出高质量的博文,设定了一个文章被点赞就能奖励现金的 机制,于是有了一个可用于点赞的API,只需传入文章id即可:

http://com.example.com/article/like?id=xxx

在安全策略上,限定必须是本站有效登录用户才可以点赞,且每个用户对每篇文章仅可点赞一 次,防止无限刷赞的情况发生。

这套机制推行起来似乎没什么问题,直我们发现有个用户的文章总是有非常多的点赞数,哪怕只是发表了一条个人状态也有非常多的点赞数,而这些点赞记录也确实都是本站的真实用户发起的。察觉到异常之后,开始对这个用户的所有行为进行排查,发现该用户几乎每篇文章都带有一张很特别的 图片,这些图片的URL无一例外地指向了对应文章的点赞API。由于图片是由浏览器自动加载的,所以每个查看过该文章的人都会不知不觉为其点赞。很显然,该用户利用了系统的CSRF漏洞实施刷赞, 这是网站开发人员始料未及的。

有人可能认为这仅仅是因为点赞API设计不理想导致的,应当使用POST请求,这样就能避免上面的场景。然而,当使用POST请求时,确实避免了如img、script、iframe等标签自动发起GET请求的问题,但这并不能杜绝CSRF攻击的发生。一些恶意网站会通过表单的形式构造攻击请求:

<form action="http://com.example.com/xxx/transfer" method="post"> 
	<input type="hidden" name="money" value="10000" />
	<input type="hidden" name="to" value="hacker" />
	<input type="submit" value="点击我查看美女图片" />
</form>

假如登录过某银行站点而没有注销,其间被诱导访问了带有类似攻击的页面,那么在该页面一旦单击按钮,很可能会导致在该银行的账户资金被直接转走。甚至根本不需要单击按钮,而是直接用 JavaScript代码自动化该过程。

CSRF利用了系统对登录期用户的信任,使得用户执行了某些并非意愿的操作从而造成损失。如
何真正地防范CSRF攻击,对每个有安全需求的系统而言都尤为重要。

2.CSRF的防御手段

一些工具可以检测系统是否存在 CSRF 漏洞,例如,CSRFTester,有兴趣的读者可以自行了解。

在任何情况下,都应当尽可能地避免以GET方式提供涉及数据修改的API。在此基础上,防御 CSRF攻击的方式主要有以下两种。

(1)HTTPReferer HTTP Referer

是由浏览器添加的一个请求头字段,用于标识请求来源,通常用在一些统计相关的 场景,浏览器端无法轻易篡改该值。

回到前面构造POST请求实行CSRF攻击的场景,其必要条件就是诱使用户跳转到第三方页面,在第三方页面构造发起的POST请求中,HTTP Referer字段不是银行的URL(少部分老版本的IE浏览器可以调用API进行伪造,但最后的执行逻辑是放在用户浏览器上的,只要用户的浏览器版本较新,便可以避免这个问题),当校验到请求来自其他站点时,可以认为是CSRF攻击,从而拒绝该服务。

当然,这种方式简单便捷,但并非完全可靠。除前面提到的部分浏览器可以篡改 HTTP Referer 外,如果用户在浏览器中设置了不被跟踪,那么HTTP Referer字段就不会自动添加,当合法用户访问时,系统会认为是CSRF攻击,从而拒绝访问。

(2)CsrfToken认证 

CSRF是利用用户的登录态进行攻击的,而用户的登录态记录在cookie中。其实攻击者并不知道用户的cookie存放了哪些数据,于是想方设法让用户自身发起请求,这样浏览器便会自行将cookie传送到服务器完成身份校验。

CsrfToken的防范思路是,添加一些并不存放于 cookie 的验证值,并在每个请求中都进行校验, 便可以阻止CSRF攻击。 具体做法是在用户登录时,由系统发放一个CsrfToken值,用户携带该CsrfToken值与用户名、密码等参数完成登录。系统记录该会话的CsrfToken 值,之后在用户的任何请求中,都必须带上该 CsrfToken值,并由系统进行校验。

这种方法需要与前端配合,包括存储CsrfToken值,以及在任何请求中(包括表单和Ajax)携带 CsrfToken值。安全性相较于HTTP Referer提高很多,但也存在一定的弊端。例如,在现有的系统中进行改造时,前端的工作量会非常大,几乎要对所有请求进行处理。如果都是XMLHttpRequest,则可以统一添加CsrfToken值;但如果存在大量的表单和a标签,就会变得非常烦琐。因此建议在系统开发之初考虑如何防御CSRF攻击。

3.使用Spring Security防御CSRF攻击

CSRF攻击完全是基于浏览器进行的,如果我们的系统前端并非在浏览器中运作,就应当关闭CSRFSpring Security通过注册一个CsrfFilter来专门处理CSRF攻击。

Spring Security中,CsrfToken是一个用于描述Token值,以及验证时应当获取哪个请求参数或请求头字段的接口。

public interface CsrfToken extends Serializable {

    String getHeaderName();

    String getParameterName();

    String getToken();
}

CsrfTokenRepository则定义了如何生成、保存以及加载CsrfToken。

public interface CsrfTokenRepository {

    CsrfToken generateToken(HttpServletRequest request);

    void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response);

    CsrfToken loadToken(HttpServletRequest request);
}

在默认情况下,Spring Security加载的是一个HttpSessionCsrfTokenRepository

/**
 * A {@link CsrfTokenRepository} that stores the {@link CsrfToken} in the
 * {@link HttpSession}.
 *
 * @author Rob Winch
 * @since 3.2
 */
public final class HttpSessionCsrfTokenRepository implements CsrfTokenRepository {
	private static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf";

	private static final String DEFAULT_CSRF_HEADER_NAME = "X-CSRF-TOKEN";

	private static final String DEFAULT_CSRF_TOKEN_ATTR_NAME = HttpSessionCsrfTokenRepository.class
			.getName().concat(".CSRF_TOKEN");

	private String parameterName = DEFAULT_CSRF_PARAMETER_NAME;

	private String headerName = DEFAULT_CSRF_HEADER_NAME;

	private String sessionAttributeName = DEFAULT_CSRF_TOKEN_ATTR_NAME;

	/*
	 * (non-Javadoc)
	 *
	 * @see org.springframework.security.web.csrf.CsrfTokenRepository#saveToken(org.
	 * springframework .security.web.csrf.CsrfToken,
	 * javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse)
	 */
	public void saveToken(CsrfToken token, HttpServletRequest request,
			HttpServletResponse response) {
		if (token == null) {
			HttpSession session = request.getSession(false);
			if (session != null) {
				session.removeAttribute(this.sessionAttributeName);
			}
		}
		else {
			HttpSession session = request.getSession();
			session.setAttribute(this.sessionAttributeName, token);
		}
	}

	/*
	 * (non-Javadoc)
	 *
	 * @see
	 * org.springframework.security.web.csrf.CsrfTokenRepository#loadToken(javax.servlet
	 * .http.HttpServletRequest)
	 */
	public CsrfToken loadToken(HttpServletRequest request) {
		HttpSession session = request.getSession(false);
		if (session == null) {
			return null;
		}
		return (CsrfToken) session.getAttribute(this.sessionAttributeName);
	}

	/*
	 * (non-Javadoc)
	 *
	 * @see org.springframework.security.web.csrf.CsrfTokenRepository#generateToken(javax.
	 * servlet .http.HttpServletRequest)
	 */
	public CsrfToken generateToken(HttpServletRequest request) {
		return new DefaultCsrfToken(this.headerName, this.parameterName,
				createNewToken());
	}

	/**
	 * Sets the {@link HttpServletRequest} parameter name that the {@link CsrfToken} is
	 * expected to appear on
	 * @param parameterName the new parameter name to use
	 */
	public void setParameterName(String parameterName) {
		Assert.hasLength(parameterName, "parameterName cannot be null or empty");
		this.parameterName = parameterName;
	}

	/**
	 * Sets the header name that the {@link CsrfToken} is expected to appear on and the
	 * header that the response will contain the {@link CsrfToken}.
	 *
	 * @param headerName the new header name to use
	 */
	public void setHeaderName(String headerName) {
		Assert.hasLength(headerName, "headerName cannot be null or empty");
		this.headerName = headerName;
	}

	/**
	 * Sets the {@link HttpSession} attribute name that the {@link CsrfToken} is stored in
	 * @param sessionAttributeName the new attribute name to use
	 */
	public void setSessionAttributeName(String sessionAttributeName) {
		Assert.hasLength(sessionAttributeName,
				"sessionAttributename cannot be null or empty");
		this.sessionAttributeName = sessionAttributeName;
	}

	private String createNewToken() {
		return UUID.randomUUID().toString();
	}
}

HttpSessionCsrfTokenRepositoryCsrfToken 值存储在HttpSession 中,并指定前端把CsrfToken 值放在名为“_csrf”的请求参数或名为“X-CSRF-TOKEN”的请求头字段里(可以调用相应的设置方法来 重新设定)。校验时,通过对比HttpSession内存储的CsrfToken值与前端携带的CsrfToken值是否一致, 便能断定本次请求是否为CSRF攻击。

当使用HttpSessionCsrfTokenRepository时,前端必须用服务器渲染的方式注入CsrfToken值。

这种方式在某些单页应用中局限性较大,灵活性不足。 Spring Security还提供了另一种方式,即CookieCsrfTokenRepository

/**
 * A {@link CsrfTokenRepository} that persists the CSRF token in a cookie named
 * "XSRF-TOKEN" and reads from the header "X-XSRF-TOKEN" following the conventions of
 * AngularJS. When using with AngularJS be sure to use {@link #withHttpOnlyFalse()}.
 *
 * @author Rob Winch
 * @since 4.1
 */
public final class CookieCsrfTokenRepository implements CsrfTokenRepository {
	static final String DEFAULT_CSRF_COOKIE_NAME = "XSRF-TOKEN";

	static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf";

	static final String DEFAULT_CSRF_HEADER_NAME = "X-XSRF-TOKEN";

	private String parameterName = DEFAULT_CSRF_PARAMETER_NAME;

	private String headerName = DEFAULT_CSRF_HEADER_NAME;

	private String cookieName = DEFAULT_CSRF_COOKIE_NAME;

	private boolean cookieHttpOnly = true;

	private String cookiePath;

	private String cookieDomain;

	public CookieCsrfTokenRepository() {
	}

	@Override
	public CsrfToken generateToken(HttpServletRequest request) {
		return new DefaultCsrfToken(this.headerName, this.parameterName,
				createNewToken());
	}

	@Override
	public void saveToken(CsrfToken token, HttpServletRequest request,
			HttpServletResponse response) {
		String tokenValue = token == null ? "" : token.getToken();
		Cookie cookie = new Cookie(this.cookieName, tokenValue);
		cookie.setSecure(request.isSecure());
		if (this.cookiePath != null && !this.cookiePath.isEmpty()) {
				cookie.setPath(this.cookiePath);
		} else {
				cookie.setPath(this.getRequestContext(request));
		}
		if (token == null) {
			cookie.setMaxAge(0);
		}
		else {
			cookie.setMaxAge(-1);
		}
		cookie.setHttpOnly(cookieHttpOnly);
		if (this.cookieDomain != null && !this.cookieDomain.isEmpty()) {
			cookie.setDomain(this.cookieDomain);
		}

		response.addCookie(cookie);
	}

	@Override
	public CsrfToken loadToken(HttpServletRequest request) {
		Cookie cookie = WebUtils.getCookie(request, this.cookieName);
		if (cookie == null) {
			return null;
		}
		String token = cookie.getValue();
		if (!StringUtils.hasLength(token)) {
			return null;
		}
		return new DefaultCsrfToken(this.headerName, this.parameterName, token);
	}

	/**
	 * Sets the name of the HTTP request parameter that should be used to provide a token.
	 *
	 * @param parameterName the name of the HTTP request parameter that should be used to
	 * provide a token
	 */
	public void setParameterName(String parameterName) {
		Assert.notNull(parameterName, "parameterName is not null");
		this.parameterName = parameterName;
	}

	/**
	 * Sets the name of the HTTP header that should be used to provide the token.
	 *
	 * @param headerName the name of the HTTP header that should be used to provide the
	 * token
	 */
	public void setHeaderName(String headerName) {
		Assert.notNull(headerName, "headerName is not null");
		this.headerName = headerName;
	}

	/**
	 * Sets the name of the cookie that the expected CSRF token is saved to and read from.
	 *
	 * @param cookieName the name of the cookie that the expected CSRF token is saved to
	 * and read from
	 */
	public void setCookieName(String cookieName) {
		Assert.notNull(cookieName, "cookieName is not null");
		this.cookieName = cookieName;
	}

	/**
	 * Sets the HttpOnly attribute on the cookie containing the CSRF token.
	 * Defaults to <code>true</code>.
	 *
	 * @param cookieHttpOnly <code>true</code> sets the HttpOnly attribute, <code>false</code> does not set it
	 */
	public void setCookieHttpOnly(boolean cookieHttpOnly) {
		this.cookieHttpOnly = cookieHttpOnly;
	}

	private String getRequestContext(HttpServletRequest request) {
		String contextPath = request.getContextPath();
		return contextPath.length() > 0 ? contextPath : "/";
	}

	/**
	 * Factory method to conveniently create an instance that has
	 * {@link #setCookieHttpOnly(boolean)} set to false.
	 *
	 * @return an instance of CookieCsrfTokenRepository with
	 * {@link #setCookieHttpOnly(boolean)} set to false
	 */
	public static CookieCsrfTokenRepository withHttpOnlyFalse() {
		CookieCsrfTokenRepository result = new CookieCsrfTokenRepository();
		result.setCookieHttpOnly(false);
		return result;
	}

	private String createNewToken() {
		return UUID.randomUUID().toString();
	}

	/**
	 * Set the path that the Cookie will be created with. This will override the default functionality which uses the
	 * request context as the path.
	 *
	 * @param path the path to use
	 */
	public void setCookiePath(String path) {
		this.cookiePath = path;
	}

	/**
	 * Get the path that the CSRF cookie will be set to.
	 *
	 * @return the path to be used.
	 */
	public String getCookiePath() {
		return this.cookiePath;
	}

	/**
	 * Sets the domain of the cookie that the expected CSRF token is saved to and read from.
	 *
	 * @since 5.2
	 * @param cookieDomain the domain of the cookie that the expected CSRF token is saved to
	 * and read from
	 */
	public void setCookieDomain(String cookieDomain) {
		this.cookieDomain = cookieDomain;
	}

}

CookieCsrfTokenRepository是一种更加灵活可行的方案,它将CsrfToken值存储在用户的cookie内。首先,减少了服务器HttpSession存储的内存消耗;其次,当用cookie存储CsrfToken值时,前端可
以用JavaScript读取(需要设置该cookie的httpOnly属性为false),而不需要服务器注入参数,在使用方式上更加灵活。

有的人可能会有疑惑,存储在cookie上,不就又可以被CSRF利用了吗?事实上并不可以。 cookie 只有在同域的情况下才能被读取,所以杜绝了第三方站点跨域获取CsrfToken值的可能。CSRF攻击本身是不知道cookie内容的,只是利用了当请求自动携带cookie时可以通过身份验证的漏洞。但服务器对CsrfToken值的校验并非取自cookie,而是需要前端手动将CsrfToken值作为参数携带在请求 里,所以cookie内的CsrfToken值并没有被校验的作用,仅仅作为一个存储容器使用。

修改Spring SecuritycsrfTokenRepository

http.csrf()
		.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());

了解了CsrfTokencsrfTokenRepository之后,再来看看csrfFilter

csrfFilter的处理流程很清晰,当一个请求到达时,首先会调用csrfTokenRepositoryloadToken方法加载该会话的CsrfToken值。如果加载不到,则证明请求是首次发起的,应该生成并保存一个新的CsrfToken值。如果可以加载到CsrfToken 值,那么先排除部分不需要验证CSRF攻击的请求方法(默 认忽略了GET、HEAD、TRACE和OPTIONS)。

private static final class DefaultRequiresCsrfMatcher implements RequestMatcher {
	private final HashSet<String> allowedMethods = new HashSet<>(
			Arrays.asList("GET", "HEAD", "TRACE", "OPTIONS"));

	/*
	 * (non-Javadoc)
	 *
	 * @see
	 * org.springframework.security.web.util.matcher.RequestMatcher#matches(javax.
	 * servlet.http.HttpServletRequest)
	 */
	@Override
	public boolean matches(HttpServletRequest request) {
		return !this.allowedMethods.contains(request.getMethod());
	}
}

当请求确认需要验证时,获取其携带的CsrfToken值,并与前面加载到的CsrfToken值进行比较即可。

2016年,Spring Security社区有人指出,csrfFilter总是在创建会话时,触发生成并保存一个CsrfToken 值,即便该会话实际上用不到这个CsrfToken值。例如,当只是使用一些公开的GET类型API时,既不需要身份验证,也不需要CSRF攻击验证,那么此时保存的Csrftoken值就是浪费空间资源。

于是Spring Security新增了一个LazyCsrfTokenRepository,用来延时保存CsrfToken值(允许创建, 但只有真正使用时才会被保存)。

/**
 * A {@link CsrfTokenRepository} that delays saving new {@link CsrfToken} until the
 * attributes of the {@link CsrfToken} that were generated are accessed.
 *
 * @author Rob Winch
 * @since 4.1
 */
public final class LazyCsrfTokenRepository implements CsrfTokenRepository {
	/**
	 * The {@link HttpServletRequest} attribute name that the {@link HttpServletResponse}
	 * must be on.
	 */
	private static final String HTTP_RESPONSE_ATTR = HttpServletResponse.class.getName();

	private final CsrfTokenRepository delegate;

	/**
	 * Creates a new instance
	 * @param delegate the {@link CsrfTokenRepository} to use. Cannot be null
	 * @throws IllegalArgumentException if delegate is null.
	 */
	public LazyCsrfTokenRepository(CsrfTokenRepository delegate) {
		Assert.notNull(delegate, "delegate cannot be null");
		this.delegate = delegate;
	}

	/**
	 * Generates a new token
	 * @param request the {@link HttpServletRequest} to use. The
	 * {@link HttpServletRequest} must have the {@link HttpServletResponse} as an
	 * attribute with the name of <code>HttpServletResponse.class.getName()</code>
	 */
	@Override
	public CsrfToken generateToken(HttpServletRequest request) {
		return wrap(request, this.delegate.generateToken(request));
	}

	/**
	 * Does nothing if the {@link CsrfToken} is not null. Saving is done only when the
	 * {@link CsrfToken#getToken()} is accessed from
	 * {@link #generateToken(HttpServletRequest)}. If it is null, then the save is
	 * performed immediately.
	 */
	@Override
	public void saveToken(CsrfToken token, HttpServletRequest request,
			HttpServletResponse response) {
		if (token == null) {
			this.delegate.saveToken(token, request, response);
		}
	}

	/**
	 * Delegates to the injected {@link CsrfTokenRepository}
	 */
	@Override
	public CsrfToken loadToken(HttpServletRequest request) {
		return this.delegate.loadToken(request);
	}

	private CsrfToken wrap(HttpServletRequest request, CsrfToken token) {
		HttpServletResponse response = getResponse(request);
		return new SaveOnAccessCsrfToken(this.delegate, request, response, token);
	}

	private HttpServletResponse getResponse(HttpServletRequest request) {
		HttpServletResponse response = (HttpServletResponse) request
				.getAttribute(HTTP_RESPONSE_ATTR);
		if (response == null) {
			throw new IllegalArgumentException(
					"The HttpServletRequest attribute must contain an HttpServletResponse for the attribute "
							+ HTTP_RESPONSE_ATTR);
		}
		return response;
	}

	private static final class SaveOnAccessCsrfToken implements CsrfToken {
		private transient CsrfTokenRepository tokenRepository;
		private transient HttpServletRequest request;
		private transient HttpServletResponse response;

		private final CsrfToken delegate;

		SaveOnAccessCsrfToken(CsrfTokenRepository tokenRepository,
				HttpServletRequest request, HttpServletResponse response,
				CsrfToken delegate) {
			this.tokenRepository = tokenRepository;
			this.request = request;
			this.response = response;
			this.delegate = delegate;
		}

		@Override
		public String getHeaderName() {
			return this.delegate.getHeaderName();
		}

		@Override
		public String getParameterName() {
			return this.delegate.getParameterName();
		}

		@Override
		public String getToken() {
			saveTokenIfNecessary();
			return this.delegate.getToken();
		}

		@Override
		public String toString() {
			return "SaveOnAccessCsrfToken [delegate=" + this.delegate + "]";
		}

		@Override
		public int hashCode() {
			final int prime = 31;
			int result = 1;
			result = prime * result
					+ ((this.delegate == null) ? 0 : this.delegate.hashCode());
			return result;
		}

		@Override
		public boolean equals(Object obj) {
			if (this == obj) {
				return true;
			}
			if (obj == null) {
				return false;
			}
			if (getClass() != obj.getClass()) {
				return false;
			}
			SaveOnAccessCsrfToken other = (SaveOnAccessCsrfToken) obj;
			if (this.delegate == null) {
				if (other.delegate != null) {
					return false;
				}
			}
			else if (!this.delegate.equals(other.delegate)) {
				return false;
			}
			return true;
		}

		private void saveTokenIfNecessary() {
			if (this.tokenRepository == null) {
				return;
			}

			synchronized (this) {
				if (this.tokenRepository != null) {
					this.tokenRepository.saveToken(this.delegate, this.request,
							this.response);
					this.tokenRepository = null;
					this.request = null;
					this.response = null;
				}
			}
		}

	}
}

可以看到,LazyCsrfTokenRepository并非独立使用一个csrfTokenRepository,而是专门用于包裹其csrfTokenRepositoryLazyCsrfTokenRepository先是覆盖了原csrfTokenRepositorysaveToken方法,使得csrfFilter中的 saveToken方法失去实际的保存效果;接着又修改了generateToken,使得CsrfToken在首次调用getToken时,才真正调用saveToken方法对CsrfToken进行保存。此特性发布在Spring Security 4.1.0.RELEASE 版本中,在该版本之后,我们看到的csrfConfigurer已经默认使用 LazyCsrfTokenRepository来包裹HttpSessionCsrfTokenRepository

 

【Spring Security系列】跨域请求伪造的防护