Spring Security的CORS与CSRF(三)

2022-08-09 13:46:58 浏览数 (1)

目录

  • 跨域
    • JSONP
    • CORS
    • Spring Security启用CORS
  • CSRF
    • CSRF的攻击过程
    • CSRF的防御手段
    • 使用Spring Security防御CSRF攻击

跨域

在之前的文章[Spring Boot或Spring MVC前后端分离的项目跨域问题的解决方案]已经介绍过跨域以及跨域的解决方案。

在学习Spring Security的时候发现,Security框架也通过HttpSecurity进行链式配置解决跨域问题,是通过CORS进行解决的,随意还是会重点讲解相关CORS。

JSONP

JSONP(JSON With Padding)是一种非官方的解决方案。由于浏览器允许一些带src属性的标签跨 域,例如,iframe、script、img等,所以JSONP利用script标签可以实现跨域。

虽然JSONP 的原理很简单,几乎兼容所有浏览器,实现起来也并不困难,但只支持 GET 请求跨域, 局限性较大。对于部分不需要考虑兼容老旧浏览器的系统来说,CORS 的方案显得更为优雅、灵活。

这里还是不做多讲解。

CORS

CORS(Cross-Origin Resource Sharing)的规范中有一组新增的HTTP首部字段,允许服务器声明其 提供的资源允许哪些站点跨域使用。通常情况下,跨域请求即便在不被支持的情况下,服务器也会接 收并进行处理,在CORS的规范中则避免了这个问题。浏览器首先会发起一个请求方法为OPTIONS 的 预检请求,用于确认服务器是否允许跨域,只有在得到许可后才会发出实际请求。此外,预检请求还允许服务器通知浏览器跨域携带身份凭证(如cookie)。

CORS新增的HTTP首部字段由服务器控制,下面我们来看看常用的几个首部字段:

Access-Control-Allow-Origin允许取值为<origin>或。<origin>指被允许的站点,使用URL首部匹配原则。匹配所有站点,表示允许来自所有域的请求。但并非所有情况都简单设置即可,如果需要浏览器在发起请求时携带凭证信息,则不允许设置为*。如果设置了具体的站点信息,则响应头中的 Vary字段还需要携带Origin属性, 因为服务器对不同的域会返回不同的内容:

代码语言:javascript复制
Access-Control-Allow-Origin:https://cuizb.top
Vary: Accept-Encoding, Origin
  • Access-Control-Allow-Methods字段仅在预检请求的响应中指定有效,用于表明服务器允许跨域的 HTTP方法,多个方法之间用逗号隔开。
  • Access-Control-Allow-Headers 字段仅在预检请求的响应中指定有效,用于表明服务器允许携带的首部字段。多个首部字段之间用逗号隔开。
  • Access-Control-Max-Age 字段用于指明本次预检请求的有效期,单位为秒。在有效期内,预检请 求不需要再次发起。
  • Access-Control-Allow-Credentials字段取值为true时,浏览器会在接下来的真实请求中携带用户凭证信息(cookie等),服务器也可以使用Set-Cookie向用户浏览器写入新的cookie。注意,使用Access-Control-Allow-Credentials时,Access-Control-Allow-Origin不应该设置为。总体来说,CORS 是一种更安全的官方跨域解决方案,它依赖于浏览器和后端,即当需要用CORS来解决跨域问题时,只需要后端做出支持即可。前端在使用这些域时,基本等同于访问同源站点资源。*注意,CORS不支持IE8以下版本的浏览器。

在使用CORS时,通常有以下三种访问控制场景。

  1. 简单请求

在CORS中,并非所有的跨域访问都会触发预检请求。例如,不携带自定义请求头信息的GET 请

求、HEAD 请求,以及 Content-Type 为application/x-www-form-urlencoded、multipart/form-data或text/plain的POST请求,这类请求被称为简单请求

浏览器在发起请求时,会在请求头中自动添加一个 Origin 属性,值为当前页面的 URL 首部。当服务器返回响应时,若存在跨域访问控制属性,则浏览器会通过这些属性判断本次请求是否被允许,如果允许,则跨域成功(正常接收数据)。

这种跨域请求非常简单,只需后端在返回的响应头中添加 Access-Control-Allow-Origin 字段并填入 允许跨域访问的站点即可。

  1. 预检请求

预检请求不同于简单请求,它会发送一个 OPTIONS 请求到目标站点,以查明该请求是否安全,防止请求对目标站点的数据造成破坏。若是请求以 GET、HEAD、POST 以外的方法发起;或者使用

POST方法,但请求数据为application/x-www-form-urlencoded、multipart/form-data和text/plain以外的数据类型;再或者,使用了自定义请求头,则都会被当成预检请求类型处理。

  1. 带凭证的请求

带凭证的请求,顾名思义,就是携带了用户cookie等信息的请求。

Spring Security启用CORS

Spring Security对CORS提供了非常好的支持,只需在配置器中启用CORS支持,并编写一 个CORS配置源即可。

在之前的文章中也有提到启用CORS。相关配置类如下

代码语言:javascript复制
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

/**
 * @author: Java技术债务
 * @Date: 2021/6/5 18:45
 * Describe: SpringSecurity配置
 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@Slf4j
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/admin/test/**").hasRole("ADMIN")
                .antMatchers("/user/test/**").hasRole("USER")
                .antMatchers("/web/test/**").permitAll()
                .anyRequest().authenticated()
                .and()
                                //允许跨域
                .cors()
                .and()
                .formLogin();

    }
}

核心实现并不复杂,DefaultCorsProcessor中的handleInternal方法是处理CORS的核心,流程非常清晰。

代码语言:javascript复制
/**
 * Handle the given request.
 */
protected boolean handleInternal(ServerHttpRequest request, ServerHttpResponse response,
    CorsConfiguration config, boolean preFlightRequest) throws IOException {

    //获取request中请求域
    String requestOrigin = request.getHeaders().getOrigin();
    //校验request请求域是否被允许
    String allowOrigin = checkOrigin(config, requestOrigin);

    HttpHeaders responseHeaders = response.getHeaders();
    if (allowOrigin == null) {
            logger.debug("Reject: '"   requestOrigin   "' origin is not allowed");
            rejectRequest(response);
            return false;
        }

        //校验被允许的方法
        HttpMethod requestMethod = getMethodToUse(request, preFlightRequest);
        List<HttpMethod> allowMethods = checkMethods(config, requestMethod);
        if (allowMethods == null) {
            logger.debug("Reject: HTTP '"   requestMethod   "' is not allowed");
            rejectRequest(response);
            return false;
        }

        List<String> requestHeaders = getHeadersToUse(request, preFlightRequest);
        //校验被允许的头
        List<String> allowHeaders = checkHeaders(config, requestHeaders);
        if (preFlightRequest && allowHeaders == null) {
            logger.debug("Reject: headers '"   requestHeaders   "' are not allowed");
            rejectRequest(response);
            return false;
        }
        responseHeaders.setAccessControlAllowOrigin(allowOrigin);

        if (preFlightRequest) {
            responseHeaders.setAccessControlAllowMethods(allowMethods);
        }

        if (preFlightRequest && !allowHeaders.isEmpty()) {
            responseHeaders.setAccessControlAllowHeaders(allowHeaders);
        }

        if (!CollectionUtils.isEmpty(config.getExposedHeaders())) {
            responseHeaders.setAccessControlExposeHeaders(config.getExposedHeaders());
        }

        if (Boolean.TRUE.equals(config.getAllowCredentials())) {
            responseHeaders.setAccessControlAllowCredentials(true);
        }

        if (preFlightRequest && config.getMaxAge() != null) {
            responseHeaders.setAccessControlMaxAge(config.getMaxAge());
        }

        response.flush();
        return true;
}

CSRF

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

CSRF的攻击过程

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

https://www.cuizb.top/article/like?id=xxx

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

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

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

代码语言:javascript复制
<form action="http://127.0.0.1:8080/article/like" method="post">
    <input type="hiddn" name="id" value="xxx" />
    <input type="submit" value="诱色可餐"/>
</form>

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

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

CSRF的防御手段

一些工具可以检测系统是否存在 CSRF 漏洞,例如,CSRFTester,有兴趣的读者可以自行了解。在任何情况下,都应当尽可能地避免以GET方式提供涉及数据修改的API。在此基础上,防御 CSRF攻击的方式主要有以下两种。

  1. HTTP Referer

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

回到前面构造POST请求实行CSRF攻击的场景,其必要条件就是诱使用户跳转到第三方页面,在第三方页面构造发起的POST请求中,HTTP Referer字段不是银行的URL(少部分老版本的IE浏览器可

以调用API进行伪造,但最后的执行逻辑是放在用户浏览器上的,只要用户的浏览器版本较新,便可

以避免这个问题),当校验到请求来自其他站点时,可以认为是CSRF攻击,从而拒绝该服务。

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

时,系统会认为是CSRF攻击,从而拒绝访问。

  1. CsrfToken认证

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

服务器完成身份校验。

CsrfToken 的防范思路是,添加一些并不存放于 cookie 的验证值,并在每个请求中都进行校验,

便可以阻止CSRF攻击。

具体做法是在用户登录时,由系统发放一个CsrfToken值,用户携带该CsrfToken值与用户名、密码

等参数完成登录。系统记录该会话的 CsrfToken 值,之后在用户的任何请求中,都必须带上该

CsrfToken值,并由系统进行校验。

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

使用Spring Security防御CSRF攻击

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

在Spring Security中, CsrfToken是一个用于描述Token值,以及验证时应当获取哪个请求参数或请

求头字段的接口。

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

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

代码语言:javascript复制
/*
 * Copyright 2002-2013 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.springframework.security.web.csrf;

import java.util.UUID;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import org.springframework.util.Assert;

/**
 * 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攻击。

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

0 人点赞