使用 Spring Security 防止 CSRF 攻击

1、概览

本文将带你了解什么是跨站请求伪造(CSRF)攻击?以及如何使用 Spring Security 来防范这些攻击。

2、两种简单的 CSRF 攻击行为

CSRF 攻击有多种形式。

2.1、GET 示例

假如下面这个 GET 请求,用于一个已登录的用户向指定的银行账户 1234 转账:

GET http://bank.com/transfer?accountNo=1234&amount=100

如果攻击者想把钱从受害者的账户转到自己的账户(5678),他需要让受害者触发请求:

GET http://bank.com/transfer?accountNo=5678&amount=1000

有多种方法可以实现这一点:

  • 链接 - 攻击者可以说服/诱导受害者点击该链接,例如执行转账:

    <a href="http://bank.com/transfer?accountNo=5678&amount=1000">
        点击展示美女图片
    </a>
    
  • 图片 - 攻击者可能会使用 <img/> 标签,将目标 URL 作为图片来源。换句话说,甚至不需要点击。请求将在页面加载时自动执行:

    <img src="http://bank.com/transfer?accountNo=5678&amount=1000"/>
    

所以,涉及到敏感的业务,千万不能用 GET 请求。

2.2、POST 示例

假设转账 API 是一个 POST 请求。

POST http://bank.com/transfer

accountNo=1234&amount=100

在这种情况下,<a><img/> 标签都不起作用。

攻击者需要使用 <form>

<form action="http://bank.com/transfer" method="POST">
    <input type="hidden" name="accountNo" value="5678"/>
    <input type="hidden" name="amount" value="1000"/>
    <input type="submit" value="Show Kittens Pictures"/>
</form>

然后,使用 JavaScript 自动提交表单:

<body onload="document.forms[0].submit()">
<form>
...

2.3、实战

在 Spring 应用中模拟 CSRF 攻击。

创建一个 “银行” 应用,定义一个转账 API BankController

@Controller
public class BankController {
    private Logger logger = LoggerFactory.getLogger(getClass());

    @RequestMapping(value = "/transfer", method = RequestMethod.GET)
    @ResponseBody
    public String transfer(@RequestParam("accountNo") int accountNo, 
      @RequestParam("amount") final int amount) {
        logger.info("Transfer to {}", accountNo);
        ...
    }

    @RequestMapping(value = "/transfer", method = RequestMethod.POST)
    @ResponseStatus(HttpStatus.OK)
    public void transfer2(@RequestParam("accountNo") int accountNo, 
      @RequestParam("amount") final int amount) {
        logger.info("Transfer to {}", accountNo);
        ...
    }
}

还需要一个基本的 HTML 页面来触发银行转账操作:

<html>
<body>
    <h1>CSRF test on Origin</h1>
    <a href="transfer?accountNo=1234&amount=100">Transfer Money to John</a>

    <form action="transfer" method="POST">
        <label>Account Number</label> 
        <input name="accountNo" type="number"/>

        <label>Amount</label>         
        <input name="amount" type="number"/>

        <input type="submit">
    </form>
</body>
</html>

这是在银行应用上运行的客户端页面。

如上,通过一个简单的链接实现了 GET,通过一个简单的 <form> 实现了 POST

现在、来看看攻击者页面的样子:

<html>
<body>
    <a href="http://localhost:8080/transfer?accountNo=5678&amount=1000">Show Kittens Pictures</a>
    
    <img src="http://localhost:8080/transfer?accountNo=5678&amount=1000"/>

    <form action="http://localhost:8080/transfer" method="POST">
        <input name="accountNo" type="hidden" value="5678"/>
        <input name="amount" type="hidden" value="1000"/>
        <input type="submit" value="Show Kittens Picture">
    </form>
</body>
</html>

该页面在不同的应用上运行,即攻击者的应用。

最后,在本地运行银行应用和攻击者应用。

要使攻击奏效,用户需要使用 Session cookie 对银行应用进行身份认证。

首先,访问银行应用页面:

http://localhost:8081/spring-rest-full/csrfHome.html

它将在浏览器上设置 JSESSIONID cookie。

然后访问攻击者应用:

http://localhost:8081/spring-security-rest/api/csrfAttacker.html

追踪源自此页面的请求,能够发现那些针对银行应用的请求。由于 JSESSIONID Cookie 会自动随这些请求一起提交,Spring 会将它们视为来自银行页面的请求进行身份认证。

3、Spring MVC 应用

为了保护 MVC 应用,Spring 会在每个生成的视图中添加一个 CSRF Token。该 Token 必须在每次修改状态的 HTTP 请求(PATCH、POST、PUT 和 DELETE)中提交给服务器。这可以保护应用免受 CSRF 攻击,因为攻击者无法从自己的页面获取此 Token。

3.1、Spring Security 配置

在旧版 XML 配置(Spring Security 4 之前)中,CSRF 保护默认是禁用的,可以根据需要启用它:

<http>
    ...
    <csrf />
</http>

从 Spring Security 4.x 开始,默认启用 CSRF 保护。

该默认配置将 CSRF Token 添加到名为 _csrfHttpServletRequest 属性中。

如果需要,可以禁用此配置:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
      .csrf().disable();
    return http.build();
}

3.2、客户端配置

现在,需要在请求中包含 CSRF Token。

_csrf 属性包含以下信息:

  • token - CSRF Token 值
  • parameterName - HTML 表单参数的名称,其中必须包含 Token 值
  • headerName - HTTP Header 的名称,其中必须包含 Token 值

如果视图使用 HTML 表单,可以使用 parameterNametoken 值添加隐藏 input:

<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>

如果视图使用 JSON,则需要使用 headerNametoken 值添加 HTTP 请求头信息。

首先在 meta 标签中包含 Token 值和 Header 名称:

<meta name="_csrf" content="${_csrf.token}"/>
<meta name="_csrf_header" content="${_csrf.headerName}"/>

然后,用 JQuery 获取 meta 标签值:

var token = $("meta[name='_csrf']").attr("content");
var header = $("meta[name='_csrf_header']").attr("content");

最后,使用这些值来设置 XHR Header:

$(document).ajaxSend(function(e, xhr, options) {
    xhr.setRequestHeader(header, token);
});

4、无状态 API

无状态 API 是否需要 CSRF 保护?

如果无状态 API 使用基于 Token 的身份验证(如 JWT),就不需要 CSRF 保护。反之,如果使用 Session Cookie 进行身份验证,就需要启用 CSRF 保护

4.1、后端配置

无状态 API 无法像 MVC 配置那样添加 CSRF Token,因为它不会生成任何 HTML 视图。

在这种情况下,可以使用 CookieCsrfTokenRepository 在 Cookie 中发送 CSRF Token:

@Configuration
public class SecurityWithCsrfCookieConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
          .csrf()
          .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
        return http.build();
    }
}

此配置将为前端设置一个名为 XSRF-TOKEN 的 Cookie。由于将 HTTP-only 标志设置为 false,因此前端能使用 JavaScript 获取此 Cookie。

4.2、前端配置

通过 JavaScript 从 document.cookie 列表中搜索 XSRF-TOKEN Cookie 值。

由于该列表以字符串形式存储,因此可以使用此 regex (正则)进行检索:

const csrfToken = document.cookie.replace(/(?:(?:^|.*;\s*)XSRF-TOKEN\s*\=\s*([^;]*).*$)|^.*$/, '$1');

然后,必须向每个修改 API 状态的 REST 请求发送 Token: POST、PUT、DELETE 和 PATCH。

Spring 会通过 X-XSRF-TOKEN Header 来接收它。

只需使用 JavaScript Fetch API 设置即可:

fetch(url, {
  method: 'POST',
  body: /* 发送给服务器的请求体 */,
  headers: { 'X-XSRF-TOKEN': csrfToken },
})

5、CSRF 禁用测试

首先尝试在禁用 CSRF 时提交一个简单的 POST 请求:

@ContextConfiguration(classes = { SecurityWithoutCsrfConfig.class, ...})
public class CsrfDisabledIntegrationTest extends CsrfAbstractIntegrationTest {

    @Test
    public void givenNotAuth_whenAddFoo_thenUnauthorized() throws Exception {
        mvc.perform(
          post("/foos").contentType(MediaType.APPLICATION_JSON)
            .content(createFoo())
          ).andExpect(status().isUnauthorized());
    }

    @Test 
    public void givenAuth_whenAddFoo_thenCreated() throws Exception {
        mvc.perform(
          post("/foos").contentType(MediaType.APPLICATION_JSON)
            .content(createFoo())
            .with(testUser())
        ).andExpect(status().isCreated()); 
    } 
}

如上,通过继承 CsrfAbstractIntegrationTest 类来获取常用的测试辅助方法。

@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
public class CsrfAbstractIntegrationTest {
    @Autowired
    private WebApplicationContext context;

    @Autowired
    private Filter springSecurityFilterChain;

    protected MockMvc mvc;

    @Before
    public void setup() {
        mvc = MockMvcBuilders.webAppContextSetup(context)
          .addFilters(springSecurityFilterChain)
          .build();
    }

    protected RequestPostProcessor testUser() {
        return user("user").password("userPass").roles("USER");
    }

    protected String createFoo() throws JsonProcessingException {
        return new ObjectMapper().writeValueAsString(new Foo(randomAlphabetic(6)));
    }
}

注意,当用户拥有正确的凭证时,请求就会被成功执行,不需要额外的信息。

这意味着攻击者只需使用前面讨论过的任何攻击方式,就能入侵系统。

6、CSRF 启用测试

现在启用 CSRF 保护,看看有什么不同:

@ContextConfiguration(classes = { SecurityWithCsrfConfig.class, ...})
public class CsrfEnabledIntegrationTest extends CsrfAbstractIntegrationTest {

    @Test
    public void givenNoCsrf_whenAddFoo_thenForbidden() throws Exception {
        mvc.perform(
          post("/foos").contentType(MediaType.APPLICATION_JSON)
            .content(createFoo())
            .with(testUser())
          ).andExpect(status().isForbidden());
    }

    @Test
    public void givenCsrf_whenAddFoo_thenCreated() throws Exception {
        mvc.perform(
          post("/foos").contentType(MediaType.APPLICATION_JSON)
            .content(createFoo())
            .with(testUser()).with(csrf())
          ).andExpect(status().isCreated());
    }
}

可以看到这次测试使用了不同的安全配置,即启用了 CSRF 保护。

现在,如果不包含 CSRF Token,POST 请求将直接失败,这当然意味着先前的攻击不再可行。

此外,测试中的 csrf() 方法会创建一个 RequestPostProcessor,在请求中自动填充一个有效的 CSRF Token,以便进行测试。

7、总结

本文介绍了 CSRF 攻击的几种方式,以及如何在 Spring 应用中使用 Spring Security 来避免 CSRF 攻击。


Ref:https://www.baeldung.com/spring-security-csrf