【Spring Security】 CSRF

Metadata

title: 【Spring Security】 CSRF
date: 2023-02-02 17:17
tags:
  - 行动阶段/完成
  - 主题场景/组件
  - 笔记空间/KnowladgeSpace/ProgramSpace/ModuleSpace
  - 细化主题/Module/SpringSecurity
categories:
  - SpringSecurity
keywords:
  - SpringSecurity
description: 【Spring Security】 CSRF

【Spring Security】 CSRF

[[../../../DictSpace/CSRF|CSRF]]

Spring Security 对 servlet 环境的跨站点请求伪造 (CSRF) 支持

Security 默认开启 csrf 防范,可以通过以下配置关闭:

http.csrf().disable();// 关闭防范csrf攻击

CSRF 的原理

  1. 生成 csrfToken 保存到 HttpSession 或者 Cookie 中
  2. 请求到来时,从请求中提取 csrfToken,和保存的 csrfToken 做比较,进而判断当
    前请求是否合法。主要通过 CsrfFilter 过滤器来完成。

核心类

CsrfToken

CsrfToken 接口定义了获取消息头、请求参数、令牌等方法。

public interface CsrfToken extends Serializable {

    /**
     * Gets the HTTP header that the CSRF is populated on the response and can be placed
     * on requests instead of the parameter. Cannot be null.
     * @return the HTTP header that the CSRF is populated on the response and can be
     * placed on requests instead of the parameter
     */
    String getHeaderName();

    /**
     * Gets the HTTP parameter name that should contain the token. Cannot be null.
     * @return the HTTP parameter name that should contain the token.
     */
    String getParameterName();

    /**
     * Gets the token value. Cannot be null.
     * @return the token value
     */
    String getToken();

}

默认的实现类为 DefaultCsrfToken。

public final class DefaultCsrfToken implements CsrfToken {

    private final String token;

    private final String parameterName;

    private final String headerName;

    /**
     * Creates a new instance
     * @param headerName the HTTP header name to use
     * @param parameterName the HTTP parameter name to use
     * @param token the value of the token (i.e. expected value of the HTTP parameter of
     * parametername).
     */
    public DefaultCsrfToken(String headerName, String parameterName, String token) {
        Assert.hasLength(headerName, "headerName cannot be null or empty");
        Assert.hasLength(parameterName, "parameterName cannot be null or empty");
        Assert.hasLength(token, "token cannot be null or empty");
        this.headerName = headerName;
        this.parameterName = parameterName;
        this.token = token;
    }

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

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

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

}

CsrfTokenRepository

CsrfTokenRepository 是一个接口,定义了生成、保存、加载 CsrfToken 等 API。

public interface CsrfTokenRepository {

    /**
     * Generates a {@link CsrfToken}
     * @param request the {@link HttpServletRequest} to use
     * @return the {@link CsrfToken} that was generated. Cannot be null.
     */
    CsrfToken generateToken(HttpServletRequest request);

    /**
     * Saves the {@link CsrfToken} using the {@link HttpServletRequest} and
     * {@link HttpServletResponse}. If the {@link CsrfToken} is null, it is the same as
     * deleting it.
     * @param token the {@link CsrfToken} to save or null to delete
     * @param request the {@link HttpServletRequest} to use
     * @param response the {@link HttpServletResponse} to use
     */
    void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response);

    /**
     * Loads the expected {@link CsrfToken} from the {@link HttpServletRequest}
     * @param request the {@link HttpServletRequest} to use
     * @return the {@link CsrfToken} or null if none exists
     */
    CsrfToken loadToken(HttpServletRequest request);

}

实现类:

HttpSessionCsrfTokenRepository 是一个基于 HttpSession 保存 csrf token 的存储库实现。

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;
    // 保存csrf token到http session
    @Override
    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);
        }
    }
    // 从http session中提取csrf token
    @Override
    public CsrfToken loadToken(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session == null) {
            return null;
        }
        return (CsrfToken) session.getAttribute(this.sessionAttributeName);
    }
    // 生成csrf token,值为一个随机UUID
    @Override
    public CsrfToken generateToken(HttpServletRequest request) {
        return new DefaultCsrfToken(this.headerName, this.parameterName, createNewToken());
    }
}

CookieCsrfTokenRepository 默认会将 csrf token 以名 XSRF-TOKEN 写入 cookie
,然后接收请求时从名为 X-XSRF-TOKEN 的 header 或者名为_csrf 的 http 请求参数读取 csrf token。但是 CookieCsrfTokenRepository 写如的 cookie 默认具有 cookieHttpOnly 属性,前端 js 是不能操作它的,这就需要在 spring security 的配置中将 cookieHttpOnly 属性设为 false。

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;

    private Boolean secure;

    private int cookieMaxAge = -1;

    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((this.secure != null) ? this.secure : request.isSecure());
        cookie.setPath(StringUtils.hasLength(this.cookiePath) ? this.cookiePath : this.getRequestContext(request));
        cookie.setMaxAge((token != null) ? this.cookieMaxAge : 0);
        cookie.setHttpOnly(this.cookieHttpOnly);
        if (StringUtils.hasLength(this.cookieDomain)) {
            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);
    }
}

CsrfFilter

CsrfFilter 用于处理跨站请求伪造。检查表单提交的_csrf 隐藏域的 value 与内存中保存的的是否一致,如果一致框架则认为当然登录页面是安全的,如果不一致,会报 403forbidden 错误。但是使用自定义的登录页面时,需要关闭 CsrfFilter 过滤器,因为自定义的页面并没有那样的一个隐藏域。

可以看到默认登录页面是加了隐藏域。

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        // 1. request中获取csrf令牌
        request.setAttribute(HttpServletResponse.class.getName(), response);
        CsrfToken csrfToken = this.tokenRepository.loadToken(request);
        boolean missingToken = (csrfToken == null);
        // 2. request中没有令牌,调用tokenRepository生成保存令牌
        if (missingToken) {
            csrfToken = this.tokenRepository.generateToken(request);
            this.tokenRepository.saveToken(csrfToken, request, response);
        }
        // 3. request中设置令牌
        request.setAttribute(CsrfToken.class.getName(), csrfToken);
        request.setAttribute(csrfToken.getParameterName(), csrfToken);
        if (!this.requireCsrfProtectionMatcher.matches(request)) {
            if (this.logger.isTraceEnabled()) {
                this.logger.trace("Did not protect against CSRF since request did not match "
                        + this.requireCsrfProtectionMatcher);
            }
            filterChain.doFilter(request, response);
            return;
        }
        String actualToken = request.getHeader(csrfToken.getHeaderName());
        if (actualToken == null) {
            actualToken = request.getParameter(csrfToken.getParameterName());
        }
        // 4. 校验不通过,抛出CsrfToken非法异常
        if (!equalsConstantTime(csrfToken.getToken(), actualToken)) {
            this.logger.debug(
                    LogMessage.of(() -> "Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request)));
            AccessDeniedException exception = (!missingToken) ? new InvalidCsrfTokenException(csrfToken, actualToken)
                    : new MissingCsrfTokenException(actualToken);
            this.accessDeniedHandler.handle(request, response, exception);
            return;
        }
        filterChain.doFilter(request, response);
    }

流程分析

点击登录,我们 F12 可以看到当前 login 接口,传输了一个_csrf 令牌。

之后请求进入 CsrfFilter,首先会在 tokenRepository 查询后端保存的令牌。

然后从 Header 或者 Parameter 中获取 token

接下来调用 equalsConstantTime 方法,对前后端的令牌进行校验。

如果校验不通过,抛出 InvalidCsrfTokenException 异常,并调用访问拒绝处理器进行处理。

对于非登录接口,我们这里配置的是 GET 请求,而调用 requireCsrfProtectionMatcher.matches 判断请求方法是否是 GET,HEAD,TRACE,OPTIONS,那么我们这个 GET 请求则不会使用 CSRF 防御。

也就是说只有 GET|HEAD|TRACE|OPTIONS 这 4 类方法会被放行,其它 Method 的 http 请求,都要验证_csrf 的 token 是否正确,而通常 post 方式调用 rest 服务时,又没有_csrf 的 token,所以校验失败。此时需要处理 Post 请求,访问时携带后端传过来的 csrftoken。

也就是说只有 GET|HEAD|TRACE|OPTIONS 这 4 类方法会被放行,其它 Method 的 http 请求,都要验证_csrf 的 token 是否正确,而通常 post 方式调用 rest 服务时,又没有_csrf 的 token,所以校验失败。此时需要处理 Post 请求,访问时携带后端传过来的 csrftoken。