【Spring Security系列】跨域请求伪造的防护
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攻击完全是基于浏览器进行的,如果我们的系统前端并非在浏览器中运作,就应当关闭CSRF。Spring 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();
}
}
HttpSessionCsrfTokenRepository将CsrfToken 值存储在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 Security的csrfTokenRepository。
http.csrf()
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
了解了CsrfToken和csrfTokenRepository之后,再来看看csrfFilter。
csrfFilter的处理流程很清晰,当一个请求到达时,首先会调用csrfTokenRepository的loadToken方法加载该会话的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,而是专门用于包裹其csrfTokenRepository。LazyCsrfTokenRepository先是覆盖了原csrfTokenRepository的saveToken方法,使得csrfFilter中的 saveToken方法失去实际的保存效果;接着又修改了generateToken,使得CsrfToken在首次调用getToken时,才真正调用saveToken方法对CsrfToken进行保存。此特性发布在Spring Security 4.1.0.RELEASE 版本中,在该版本之后,我们看到的csrfConfigurer已经默认使用 LazyCsrfTokenRepository来包裹HttpSessionCsrfTokenRepository。