Spring Security 中 CSRF 保護指南

Spring Security
Remote
0
07:51 PM · Nov 29 ,2025

1. 概述

在本教程中,我們將討論跨站請求偽造(CSRF)攻擊以及如何使用 Spring Security 來防止它們。

2. Two Simple CSRF Attacks

There are multiple forms of CSRF attacks. Let’s discuss some of the most common ones.

2.1. GET Examples

Let’s consider the following GET request used by a logged-in user to transfer money to a specific bank account 1234:

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

If the attacker wants to transfer money from a victim’s account to his own account instead — 5678 — he needs to make the victim trigger the request:

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

There are multiple ways to make that happen:

  • Link – The attacker can convince the victim to click on this link, for example, to execute the transfer:
<a href="http://bank.com/transfer?accountNo=5678&amount=1000">
Show Kittens Pictures
</a>
  • Image– The attacker may use an tag with the target URL as the image source. In other words, the click isn’t even necessary. The request will be automatically executed when the page loads:
<img src="http://bank.com/transfer?accountNo=5678&amount=1000"/>

2.2. POST Example

Suppose the main request needs to be a POST request:

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

In this case, the attacker needs to have the victim run a similar request:

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

Neither the <a> nor the <img/> tags will work in this case.

The attacker will need a <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>

However, the form can be submitted automatically using JavaScript:

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

2.3. Practical Simulation

Now that we understand what a CSRF attack looks like, let’s simulate these examples within a Spring app.

We’re going to start with a simple controller implementation — the 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);
        ...
    }
}

And let’s also have a basic HTML page that triggers the bank transfer operation:

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

This is the page of the main application, running on the origin domain.

We should note that we’ve implemented a GET through a simple link and a POST through a simple <form>.

Now let’s see what the attacker page would look like:

<html>
<body>
    <a href="http://localhost:8080/spring-rest-full/csrfHome.html">Show Kittens Pictures</a>

    <img src="http://localhost:8080/spring-rest-full/csrfHome.html">

    <form action="http://localhost:8080/spring-rest-full/api/csrfAttacker.html" 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>

This page will run on a different domain — the attacker domain.

Finally, let’s run both the original application and the attacker application locally.

To make the attack work, the user needs to be authenticated to the original application with a session cookie.

Let’s first access the original application page:

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

It will set the JSESSIONID cookie on our browser.

Then let’s access the attacker page:

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

If we track the requests that originated from this attacker page, we’ll be able to spot the ones that hit the original application. As the JSESSIONID cookie is automatically submitted with these requests, Spring authenticates them as if they were coming from the original domain.

3. Spring MVC 應用程序

為了保護 MVC 應用程序,Spring 會為每個生成的視圖添加 CSRF 令牌。該令牌必須在對狀態進行修改的每個 HTTP 請求中提交(PATCH、POST、PUT 和 DELETE — 不包括 GET)。這可以保護我們的應用程序免受 CSRF 攻擊,因為攻擊者無法從自己的頁面獲取此令牌。

接下來,我們將瞭解如何配置應用程序安全性和如何使我們的客户端符合這些要求。

3.1. Spring Security 配置

在較早的 XML 配置(在 Spring Security 4 之前),CSRF 保護默認情況下已禁用,並且我們可以按需啓用它:

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

從 Spring Security 4.x 開始,CSRF 保護默認已啓用。

此默認配置會將 CSRF 令牌添加到 HttpServletRequest 屬性 _csrf

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

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

3.2. 客户端配置

現在我們需要在請求中包含 CSRF 令牌。

_csrf 屬性包含以下信息:

  • token – CSRF 令牌值
  • parameterName – HTML 表單參數的名稱,必須包含令牌值
  • headerName – HTTP 標頭名稱,必須包含令牌值

如果我們的視圖使用 HTML 表單,我們將使用 parameterNametoken 值來添加隱藏輸入:

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

如果我們的視圖使用 JSON,我們需要使用 headerNametoken 值來添加 HTTP 標頭。

我們首先需要將令牌值和標頭名稱包含在 meta 標籤中:

<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 標頭:

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

4. Stateless Spring API

讓我們回顧一下 stateless Spring API 被前端消費的情況。

正如我們在專門的文章中所解釋的,我們需要理解 stateless API 是否需要 CSRF 保護。

如果我們的 stateless API 使用基於令牌的身份驗證,例如 JWT,我們就不需要 CSRF 保護,並且應該將其禁用,正如我們之前所見。

然而,如果我們的 stateless API 使用會話 Cookie 身份驗證,則需要啓用 CSRF 保護,正如我們稍後將看到的。

4.1. 後端配置

我們的 stateless API 無法像 MVC 配置一樣添加 CSRF 令牌,因為它不生成任何 HTML 視圖。

在這種情況下,我們可以使用 CookieCsrfTokenRepository 將 CSRF 令牌發送到 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 到前端。由於我們將 HTTP-only 標誌設置為 false,前端將可以使用 JavaScript 從該 Cookie 中檢索它。

4.2. 前端配置

使用 JavaScript,我們需要從 document.cookie 列表中搜索 XSRF-TOKEN Cookie 的值。

由於此列表存儲為字符串,我們可以使用以下正則表達式檢索它:

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

然後我們必須將令牌發送到所有修改 API 狀態的 REST 請求:POST、PUT、DELETE 和 PATCH。

Spring 期望在 X-XSRF-TOKEN 標頭中接收它。

我們可以使用 JavaScript 的 Fetch API 簡單地將其設置:

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

5. CSRF 已禁用測試

一切準備就緒後,我們來進行一些測試。

首先,我們嘗試提交一個簡單的 POST 請求,當 CSRF 已禁用時:

@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 保護的配置。

現在,POST 請求如果未包含 CSRF 令牌將簡單失敗,這當然意味着以前的攻擊不再有效。

此外,測試中的 csrf()方法創建了一個 RequestPostProcessor,該方法在測試目的下自動在請求中填充有效的 CSRF 令牌。

7. 結論

在本文中,我們討論了幾個 CSRF 攻擊以及如何使用 Spring Security 來防止它們。

user avatar
0 位用戶收藏了這個故事!
收藏

發佈 評論

Some HTML is okay.