1. 概述

本文将介绍跨站请求伪造(CSRF)攻击的原理,并演示如何通过 Spring Security 实现有效的防护机制。

2. 两种常见的 CSRF 攻击

CSRF 攻击形式多样,我们来看最常见的两种。

2.1. GET 请求攻击示例

假设用户已登录银行系统,执行如下 GET 请求转账:

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">
Show Kittens Pictures
</a>

图片标签攻击:使用 <img> 标签自动加载恶意请求:

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

2.2. POST 请求攻击示例

若转账请求为 POST:

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

攻击者则需要构造如下请求:

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

此时 <a><img> 不再适用,需使用表单:

<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 构建一个简单的控制器来模拟攻击场景:

@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>
<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>

攻击者页面(恶意):

<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。

3. Spring MVC 应用防护

3.1. Spring Security 配置

在 Spring Security 4.x 之后,CSRF 防护默认开启。系统会自动将 CSRF token 添加到 _csrf 属性中。

禁用 CSRF(不推荐):

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

3.2. 客户端配置

前端需将 _csrf 中的 token 信息提交回服务器。

HTML 表单中添加隐藏字段:

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

JSON 请求则需设置 HTTP Header:

先在页面中添加 meta 标签:

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

然后使用 JavaScript 设置 header:

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

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

4. 无状态 Spring API 的防护

4.1. 后端配置

对于无状态 API,若使用 JWT 等 token 认证机制,则无需开启 CSRF 防护。

若仍使用 session cookie,则需启用并配置如下:

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

这将返回一个名为 XSRF-TOKEN 的 cookie,前端可通过 JS 获取。

4.2. 前端配置

document.cookie 中提取 token:

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

每次请求需在 header 中携带:

fetch(url, {
  method: 'POST',
  body: /* data to send */,
  headers: { 'X-XSRF-TOKEN': csrfToken },
})

5. CSRF 禁用下的测试

当 CSRF 被禁用时,攻击者可以轻松发起请求:

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

✅ 请求成功,无额外校验。

6. CSRF 启用后的测试

开启 CSRF 后,请求必须携带 token:

@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());
}

❌ 无 token 则请求失败
✅ 使用 csrf() 方法可模拟 token

7. 总结

本文通过示例讲解了 CSRF 攻击的原理,并演示了如何在 Spring Security 中通过 CSRF token 机制进行有效防护。

要点回顾

  • CSRF 攻击利用用户已登录状态伪造请求
  • Spring Security 默认启用 CSRF 防护
  • MVC 应用通过 _csrf 属性注入 token
  • 无状态 API 若使用 session cookie 仍需防护
  • 前端必须配合后端将 token 正确传递

完整示例代码可参考 GitHub 项目


原始标题:A Guide to CSRF Protection in Spring Security