认识CSRF攻击
小明的悲惨遭遇
例子来自美团的https://tech.meituan.com/2018/10/11/fe-security-csrf.html
这一天,小明同学百无聊赖地刷着Gmail邮件。大部分都是没营养的通知、验证码、聊天记录之类。但有一封邮件引起了小明的注意:
甩卖比特币,一个只要998!!
聪明的小明当然知道这种肯定是骗子,但还是抱着好奇的态度点了进去(请勿模仿)。果然,这只是一个什么都没有的空白页面,小明失望的关闭了页面。一切似乎什么都没有发生……
在这平静的外表之下,黑客的攻击已然得手。小明的Gmail中,被偷偷设置了一个过滤规则,这个规则使得所有的邮件都会被自动转发到hacker@hackermail.com。小明还在继续刷着邮件,殊不知他的邮件正在一封封地,如脱缰的野马一般地,持续不断地向着黑客的邮箱转发而去。
不久之后的一天,小明发现自己的域名已经被转让了。懵懂的小明以为是域名到期自己忘了续费,直到有一天,对方开出了 $650 的赎回价码,小明才开始觉得不太对劲。
小明仔细查了下域名的转让,对方是拥有自己的验证码的,而域名的验证码只存在于自己的邮箱里面。小明回想起那天奇怪的链接,打开后重新查看了“空白页”的源码:
1 | <form method="POST" action="https://mail.google.com/mail/h/ewt1jmuj4ddv/?v=prf" enctype="multipart/form-data"> |
这个页面只要打开,就会向Gmail发送一个post请求。请求中,执行了“Create Filter”命令,将所有的邮件,转发到“hacker@hackermail.com”。
小明由于刚刚就登陆了Gmail,所以这个请求发送时,携带着小明的登录凭证(Cookie),Gmail的后台接收到请求,验证了确实有小明的登录凭证,于是成功给小明配置了过滤器。
黑客可以查看小明的所有邮件,包括邮件里的域名验证码等隐私信息。拿到验证码之后,黑客就可以要求域名服务商把域名重置给自己。
小明很快打开Gmail,找到了那条过滤器,将其删除。然而,已经泄露的邮件,已经被转让的域名,再也无法挽回了……
以上就是小明的悲惨遭遇。而“点开一个黑客的链接,所有邮件都被窃取”这种事情并不是杜撰的,此事件原型是2007年Gmail的CSRF漏洞:
https://www.davidairey.com/google-Gmail-security-hijack/
当然,目前此漏洞已被Gmail修复,请使用Gmail的同学不要慌张。
什么是 CSRF
在之前,我们配置 SecurityConfig 的时候,一直有一个配置就是
.csrf(),回忆一下之前使用 Spring Security
默认页登录的时候,该配置 Spring Security 默认开启,主要做用于 CSRF
防护
CSRF(Cross-Site Request Forgery ,也就是跨站请求伪造),也可称为一键式攻击(one-click-attack)或 Session Riding(会话劫持),通常缩写为 CSRF 或者 XSRF。
CSRF 攻击是一种挟持用户在当前已登录的浏览器上发送恶意请求的攻击方法。
相对于 xss 利用用户对指定网站的信任,CSRD 则是利用网站对用户网页浏览器的信任。简单总结:XSS 是 “偷” 你的信息,CSRF 是 “借” 你的身份去做事。
它是一种常见的网络攻击手段,攻击者诱导用户在已登录目标网站的状态下,去访问一个恶意网站或点击恶意链接,从而在用户不知情的情况下,以用户的身份向目标网站发送伪造的请求,执行攻击者预设的操作。
什么意思,那些 Steam 各种骗子,伪装成一个你看不出来的官方的身份,给你发了一个看似官方的欺骗性地址,你在里面登录了,你 Steam 号就没了
攻击者盗用了你的身份,以你的名义做了某件事(比如转账、改密码、发帖子),而目标网站会误以为这个请求是你主动发起的。
在这里说一下同站与跨站的判定:浏览器的 “同站” 判定是基于注册域名 + 端口,而非子域名。
CSRF 攻击原理
要理解 CSRF 的原理,首先要明白 HTTP 会话认证机制 的特点
绝大多数网站是基于 Cookie 来维持用户登录状态的。当用户登录网站后,网站会在用户浏览器中种下一个包含身份信息的 Cookie,后续用户向该网站发送的所有请求,浏览器都会自动携带这个 Cookie,网站通过验证 Cookie 来确认用户身份。
CSRF(Cross-site request forgery)跨站请求伪造:攻击者诱导受害者进入第三方网站,在第三方网站中,向被攻击网站发送跨站请求。利用受害者在被攻击网站已经获取的注册凭证,绕过后台的用户验证,达到冒充用户对被攻击的网站执行某项操作的目的。
CSRF 攻击正是利用了 浏览器自动携带 Cookie 这个特性,其攻击流程可以分为 4 步:
用户登录可信网站 A
用户在浏览器中登录了目标网站 A(比如网上银行、电商平台),网站 A 在用户浏览器中种下了登录状态的 Cookie,此时用户处于已认证状态
用户未退出网站 A,访问恶意网站 B
用户没有退出网站 A,就打开了一个新标签页,访问了攻击者搭建的恶意网站 B。
恶意网站 B 发送伪造请求到网站 A
恶意网站 B 的页面中,隐藏了一段代码(比如
<img>标签、<form>表单、JavaScript 脚本),这段代码会自动向网站 A 的某个接口发送请求(比如转账接口、修改个人信息接口)。例如:
1
2<!-- 恶意网站B中的隐藏代码,伪造转账请求 -->
<img src="https://www.网站A.com/transfer?to=攻击者账户&amount=1000" style="display:none;">浏览器自动携带 Cookie,网站 A 执行请求
当浏览器加载这段代码时,会向网站 A 发送转账请求,并且自动携带网站 A 的登录 Cookie
网站 A 收到请求后,验证 Cookie 发现用户是已登录状态,就会认为这个请求是用户主动发起的,进而执行转账操作。
整个过程中,用户完全不知情,攻击就已经完成了。
那么,整个流程中,CSRF 攻击的关键前提是什么
用户必须已登录目标网站 A
如果用户没有登录网站 A,浏览器中没有网站 A 的登录 Cookie,那么伪造的请求会因为身份验证失败而被拒绝。
目标网站的接口是 “不安全的”
目标网站的接口只验证了用户的登录状态(Cookie),但没有验证请求是否是用户主动发起的
用户需要被诱导访问恶意网站
攻击者需要通过钓鱼邮件、恶意链接、论坛广告等方式,诱导用户在登录状态下访问恶意页面。
CSRF的特点
- 攻击一般发起在第三方网站,而不是被攻击的网站。被攻击的网站无法防止攻击发生。
- 攻击利用受害者在被攻击网站的登录凭证,冒充受害者提交操作;而不是直接窃取数据。
- 整个过程攻击者并不能获取到受害者的登录凭证,仅仅是“冒用”。
- 跨站请求可以用各种方式:图片URL、超链接、CORS、Form提交等等。部分请求方式可以直接嵌入在第三方论坛、文章中,难以进行追踪。
CSRF通常是跨域的,因为外域通常更容易被攻击者掌控。但是如果本域下有容易被利用的功能,比如可以发图和链接的论坛和评论区,攻击可以直接在本域下进行,而且这种攻击更加危险。
几种常见的攻击类型
GET类型的CSRF
GET类型的CSRF利用非常简单,只需要一个HTTP请求,一般会这样利用:
1 |  |
在受害者访问含有这个img的页面后,浏览器会自动向http://bank.example/withdraw?account=xiaoming&amount=10000&for=hacker发出一次HTTP请求。bank.example就会收到包含受害者登录信息的一次跨域请求。
POST类型的CSRF
这种类型的CSRF利用起来通常使用的是一个自动提交的表单,如:
1 | <form action="http://bank.example/withdraw" method=POST> |
访问该页面后,表单会自动提交,相当于模拟用户完成了一次POST操作。
POST类型的攻击通常比GET要求更加严格一点,但仍并不复杂。任何个人网站、博客,被黑客上传页面的网站都有可能是发起攻击的来源,后端接口不能将安全寄托在仅允许POST上面。
链接类型的CSRF
链接类型的CSRF并不常见,比起其他两种用户打开页面就中招的情况,这种需要用户点击链接才会触发。这种类型通常是在论坛中发布的图片中嵌入恶意链接,或者以广告的形式诱导用户中招,攻击者通常会以比较夸张的词语诱骗用户点击,例如:
1 | <a href="http://test.com/csrf/withdraw.php?amount=1000&for=hacker" taget="_blank"> |
由于之前用户登录了信任的网站A,并且保存登录状态,只要用户主动访问上面的这个PHP页面,则表示攻击成功。
防御 CSRF 是思路是什么
上文中讲了CSRF的两个特点:
- CSRF(通常)发生在第三方域名。
- CSRF攻击者不能获取到Cookie等信息,只是使用。
针对这两点,我们可以专门制定防护策略,如下:
- 阻止不明外域的访问
- 同源检测
- Samesite Cookie
- 提交时要求附加本域才能获取的信息
- CSRF Token
- 双重Cookie验证
常见的防御手段有以下几种:
添加 CSRF Token
这是最主流、最有效的防御方式。也是 Spring Security 的默认防御模式
CSRF攻击之所以能够成功,是因为服务器误把攻击者发送的请求当成了用户自己的请求。那么我们可以要求所有的用户请求都携带一个CSRF攻击者无法获取到的Token。服务器通过校验请求是否携带正确的Token,来把正常的请求和攻击的请求区分开,也可以防范CSRF的攻击。
网站在生成页面时,会给每个用户生成一个唯一的 CSRF Token(随机字符串),并将 Token 存储在 Session 中,同时在页面的表单或请求头中嵌入这个 Token。
当用户发送请求时,需要同时携带这个 Token,网站会验证请求中的 Token 和 Session 中的 Token 是否一致。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23HttpServletRequest req = (HttpServletRequest)request;
HttpSession s = req.getSession();
// 从 session 中得到 csrftoken 属性
String sToken = (String)s.getAttribute(“csrftoken”);
if(sToken == null){
// 产生新的 token 放入 session 中
sToken = generateToken();
s.setAttribute(“csrftoken”,sToken);
chain.doFilter(request, response);
} else{
// 从 HTTP 头中取得 csrftoken
String xhrToken = req.getHeader(“csrftoken”);
// 从请求参数中取得 csrftoken
String pToken = req.getParameter(“csrftoken”);
if(sToken != null && xhrToken != null && sToken.equals(xhrToken)){
chain.doFilter(request, response);
}else if(sToken != null && pToken != null && sToken.equals(pToken)){
chain.doFilter(request, response);
}else{
request.getRequestDispatcher(“error.jsp”).forward(request,response);
}
}攻击者无法获取用户的 CSRF Token,因此伪造的请求会因为缺少有效 Token 而被拒绝。
同源检测
验证请求的 Referer/Origin 头
既然CSRF大多来自第三方网站,那么我们就直接禁止外域(或者不受信任的域名)对我们发起请求。那么问题来了,我们如何判断请求是否来自外域呢?
在HTTP协议中,每一个异步请求都会携带两个Header,用于标记来源域名:
- Referer 头:记录了请求的来源页面地址,网站可以检查 Referer 是否为自己的域名,如果不是则拒绝请求。
- Origin 头:比 Referer 更安全,只包含域名,不包含具体路径,避免了 Referer 可能被篡改的风险。
这两个Header在浏览器发起请求时,大多数情况会自动带上,并且不能由前端自定义内容。 服务器可以通过解析这两个Header中的域名,确定请求的来源域。
但是部分浏览器可能会因为隐私设置不发送 Referer 头。这种情况建议直接进行阻止
使用 SameSite Cookie 属性
- 给 Cookie 设置 SameSite 属性,限制 Cookie 的跨站发送:
SameSite=Strict:Cookie 仅在同站请求中携带,跨站请求绝对不携带。SameSite=Lax:允许部分安全的跨站请求携带 Cookie(比如 GET 请求),禁止 POST 等敏感请求。
- 给 Cookie 设置 SameSite 属性,限制 Cookie 的跨站发送:
要求敏感操作输入验证码 / 密码
对于转账、改密码等高危操作,强制要求用户输入验证码或密码,即使请求被伪造,也因为缺少验证码而无法执行。
2FA
最牛逼最先进最可靠的方式,伟大,无需多言
分布式校验
在大型网站中,使用Session存储CSRF Token会带来很大的压力。访问单台服务器session是同一个。但是现在的大型网站中,我们的服务器通常不止一台,可能是几十台甚至几百台之多,甚至多个机房都可能在不同的省份,用户发起的HTTP请求通常要经过像Ngnix之类的负载均衡器之后,再路由到具体的服务器上,由于Session默认存储在单机服务器内存中,因此在分布式环境下同一个用户发送的多次HTTP请求可能会先后落到不同的服务器上,导致后面发起的HTTP请求无法拿到之前的HTTP请求存储在服务器中的Session数据,从而使得Session机制在分布式环境下失效,因此在分布式集群中CSRF Token需要存储在Redis之类的公共存储空间。
由于使用Session存储,读取和验证CSRF Token会引起比较大的复杂度和性能问题,目前很多网站采用Encrypted Token Pattern方式。这种方法的Token是一个计算出来的结果,而非随机生成的字符串。这样在校验时无需再去读取存储的Token,只用再次计算一次即可。
这种Token的值通常是使用UserID、时间戳和随机数,通过加密的方法生成。这样既可以保证分布式服务的Token一致,又能保证Token不容易被破解。
在token解密成功之后,服务器可以访问解析值,Token中包含的UserID和时间戳将会被拿来被验证有效性,将UserID与当前登录的UserID进行比较,并将时间戳与当前时间进行比较。
额外的验证码和密码
为什么很多银行等网站会要求已经登录的用户在转账时再次输入密码,现在是不是有一定道理了?
虽然 CSRF
保护很重要,但在以下情况下,我们在开发的时候通常会选择禁用它http.csrf(csrf -> csrf.disable());:
- 无状态服务(纯 JWT):如果你完全不使用 Cookie
进行身份验证,而是使用
Authorization: Bearer <JWT>,那么 CSRF 攻击就无法实现,因为浏览器不会自动在 Header 中带上 JWT。 - 非浏览器客户端:如果你的 API 只提供给移动 App 或其他服务器调用。
Spring Security 如何防御 CSRF
同步令牌模式
Spring Security 主要采用 同步令牌模式(Synchronizer Token Pattern) 来防御 CSRF,而且它也是Spring Security 的默认方案
服务器在渲染每个需要保护的表单页面时,向用户 Session 中存入一个随机生成的 Token,并在表单中以隐藏字段输出
提交时,服务器验证该字段与 Session 中的 Token 是否一致,若不匹配则拒绝请求。此模式能有效防止 CSRF 攻击,因为攻击者无法从第三方域读取到该随机 Token。
- 生成令牌:当用户访问页面时,服务器生成一个随机的 CSRF Token,并将其存入服务器会话(Session)或 Cookie 中。
- 注入令牌:服务器在返回的 HTML 表单中嵌入一个隐藏字段,或者要求前端从 Cookie 中读取并放入 HTTP Header。
- 校验令牌:当用户提交
POST/PUT/DELETE等修改数据的请求时,Spring Security 的CsrfFilter会拦截请求,对比请求中的 Token 与服务器保存的 Token 是否一致。 - 拒绝非法请求:如果 Token
缺失或不匹配,服务器直接返回
403 Forbidden。
那么,这个额外的 CSRF Token 如何存储
- 基于 Session 的存储(默认)
- 组件:
HttpSessionCsrfTokenRepository - 特点:Token 存在服务器 Session 中。安全性最高,但要求后端是有状态的。
- 适用场景:传统的 Thymeleaf/JSP 模板引擎应用。
- 组件:
- 基于 Cookie 的存储
- 组件:
CookieCsrfTokenRepository - 特点:后端不存 Session,而是把 Token 写入一个名为
XSRF-TOKEN的 Cookie 中。 - 适用场景:前后端分离(SPA,如 React/Vue)。
- 实现细节:前端读取该 Cookie,并在发送 AJAX
请求时将其放入 Header(通常名为
X-XSRF-TOKEN)。
- 组件:
双重提交 Cookie
在会话中存储CSRF Token比较繁琐,而且不能在通用的拦截上统一处理所有的接口。
那么另一种防御措施是使用双重提交Cookie。利用CSRF攻击不能获取到用户Cookie的特点,我们可以要求Ajax和表单请求携带一个Cookie中的值。
它是 CSRF Token 防御的一种变体,双重Cookie采用以下流程:
生成 CSRF Token 并写入 Cookie
在用户访问网站页面时,向请求域名注入一个Cookie,内容为随机字符串,和上面一样,这个 Cookie 是可跨站携带的,但是 Token 本身是唯一的
前端请求时携带 Token
前端在发送非 GET 的敏感请求(POST/PUT/DELETE 等)时,需要将 Cookie 中的 CSRF Token 同时携带在请求参数、请求头或表单字段中。
服务器验证 Token 一致性
服务器收到请求后,会同时提取Cookie 中的 CSRF Token和请求中携带的 CSRF Token,对比两者是否一致:
- 一致:说明是用户主动发起的请求,允许执行;
- 不一致 / 缺失:判定为 CSRF 攻击,拒绝请求。
此方法相对于CSRF Token就简单了许多。可以直接通过前后端拦截的的方法自动化实现。后端校验也更加方便,只需进行请求中字段的对比,而不需要再进行查询和存储Token。
CSRF 攻击的核心是攻击者能诱导浏览器自动携带 Cookie,但无法读取 Cookie 中的内容(浏览器的同源策略限制:跨域网站无法读取其他域名的 Cookie)
因此:
- 攻击者可以诱导用户发送请求(浏览器自动带 Cookie 里的 Token),但无法获取 Cookie 中的 Token,也就无法将其添加到请求参数 / 头中;
- 服务器验证时,因为请求中没有对应的 Token,会直接拒绝,从而防御攻击。
双重提交 Cookie 是另一种配置方式,不需要服务器存储 Token,下面使用到的时候会说
sequenceDiagram
participant User
participant Browser
participant Server (Spring Security)
User->>Browser: 首次访问后端网站(如http://localhost:8080)
Browser->>Server: 发送GET请求
Server->>Server: 生成CSRF Token(如:abc123)
Server->>Browser: 响应并写入Cookie:XSRF-TOKEN=abc123
Browser->>User: 加载页面
User->>Browser: 点击按钮发送POST请求(如/api/hello)
Browser->>Browser: 读取Cookie中的XSRF-TOKEN=abc123,添加到请求头X-XSRF-TOKEN=abc123
Browser->>Server: 发送POST请求(携带Cookie:XSRF-TOKEN=abc123 + 请求头:X-XSRF-TOKEN=abc123)
Server->>Server: 提取两个Token并对比,一致则通过验证
Server->>Browser: 执行请求并返回响应
Browser->>User: 展示响应结果
Note over Browser,Server: CSRF攻击场景:攻击者诱导用户发送请求,但无法读取Cookie中的Token,请求头中无X-XSRF-TOKEN,服务器拒绝请求
双重提交 Cookie的核心是将 CSRF Token 同时放在 Cookie 和请求中,服务器验证两者一致性,利用浏览器同源策略限制攻击者读取 Cookie,从而防御 CSRF;
当然,此方法并没有大规模应用,其在大型网站上的安全性还是没有CSRF Token高,原因我们举例进行说明。
由于任何跨域都会导致前端无法获取Cookie中的字段(包括子域名之间),于是发生了如下情况:
- 如果用户访问的网站为
www.a.com,而后端的api域名为api.a.com。那么在www.a.com下,前端拿不到api.a.com的Cookie,也就无法完成双重Cookie认证。 - 于是这个认证Cookie必须被种在
a.com下,这样每个子域都可以访问。 - 任何一个子域都可以修改
a.com下的Cookie。 - 某个子域名存在漏洞被XSS攻击(例如
upload.a.com)。虽然这个子域下并没有什么值得窃取的信息。但攻击者修改了a.com下的Cookie。 - 攻击者可以直接使用自己配置的Cookie,对XSS中招的用户再向
www.a.com下,发起CSRF攻击。
SameSite Cookie 属性
SameSite是 Cookie 的一个属性(RFC 6265bis
标准),它的核心作用是限制 Cookie
在跨站请求中的发送行为,从而从根源上阻断 CSRF
攻击的关键环节(因为浏览器自动携带跨站请求的 Cookie)。
这个属性可以告诉浏览器:什么样的跨站请求中,我才允许携带这个 Cookie?
SameSite 的三个核心取值
| 取值 | 行为说明 | 对 CSRF 的防御效果 |
|---|---|---|
SameSite=Strict(严格模式) |
仅在同站请求中携带 Cookie。所谓
“同站”,是指域名 +
端口完全一致(比如https://www.example.com和https://blog.example.com不算同站,因为子域名不同);跨站请求(比如从https://malicious.com请求https://example.com)绝对不会携带
Cookie。 |
防御效果最强,几乎能阻断所有 CSRF 攻击 |
SameSite=Lax(宽松模式,浏览器默认值) |
允许部分安全的跨站 GET 请求携带 Cookie,禁止
POST/PUT/DELETE 等敏感请求携带
Cookie。允许的场景:链接跳转(<a href>)、预加载(<link rel="prefetch">)、表单
GET 提交(<form method="get">);禁止的场景:AJAX
POST 请求、表单 POST
提交、<img>/<script>的跨站请求。 |
能防御绝大多数 CSRF 攻击(因为 CSRF 攻击常用 POST 等敏感请求),同时兼顾用户体验 |
SameSite=None(无限制) |
允许所有跨站请求携带
Cookie,但必须同时设置Secure属性(即
Cookie 只能通过 HTTPS 传输)。适用于需要跨站携带 Cookie
的场景(比如第三方登录、跨域支付)。 |
无 CSRF 防御效果,需要配合其他防御手段(如 CSRF Token) |
在 Spring 生态中,配置 Cookie
的SameSite属性分两种场景:普通
Cookie和Spring Security 生成的 Cookie(如
JSESSIONID、XSRF-TOKEN)。
如果是你自己在代码中生成的 Cookie(比如用户信息
Cookie),可以直接通过Cookie对象的setSameSite方法配置:
1 | import jakarta.servlet.http.Cookie; |
那么,如何配置 Spring Security 生成的 Cookie 呢?这个下面说
在实际项目中,我们通常会同时使用 SameSite Cookie 和双重提交 Cookie,形成 “双层防护”,原因如下:
SameSite=Lax/Strict虽然能防御大部分 CSRF 攻击,但存在兼容性问题:部分老旧浏览器(如 IE)不支持SameSite属性,此时攻击仍可能发生;SameSite=None需要开启Secure(仅 HTTPS),如果项目在测试环境使用 HTTP,就无法使用;说实话很少特意去配置 None- 双重提交 Cookie
可以作为兜底方案,即使
SameSite失效,仍能防御 CSRF。
sequenceDiagram
participant Attacker
participant User
participant Browser
participant Server
Note over Attacker,Server: CSRF攻击场景
Attacker->>User: 诱导点击恶意链接(跨站POST请求到目标网站)
User->>Browser: 点击链接(已登录目标网站)
Browser->>Browser: 检查目标网站Cookie的SameSite属性
alt SameSite=Strict/Lax
Browser->>Browser: 跨站POST请求不携带Cookie
Browser->>Server: 发送POST请求(无Cookie)
Server->>Browser: 身份验证失败,拒绝请求
else SameSite=None(或浏览器不支持)
Browser->>Browser: 携带Cookie,但前端无XSRF-TOKEN(攻击者无法读取)
Browser->>Server: 发送POST请求(有Cookie,无X-XSRF-TOKEN头)
Server->>Browser: 双重提交Cookie验证失败,拒绝请求
end
Browser->>User: 请求失败,攻击被阻断
这个方法也存在很多问题,就是使用起来不太好使用,因为业务情况通常不能一概而论
而且,问题是Samesite的兼容性不是很好,现阶段除了从新版Chrome和Firefox支持以外,Safari以及iOS Safari都还不支持,现阶段看来暂时还不能普及。
而且,SamesiteCookie目前有一个致命的缺陷:不支持子域。例如,种在topic.a.com下的Cookie,并不能使用a.com下种植的SamesiteCookie。这就导致了当我们网站有多个子域名时,不能使用SamesiteCookie在主域名存储用户登录信息。每个子域名都需要用户重新登录一次。
总之,SamesiteCookie是一个可能替代同源验证的方案,但目前还并不成熟,其应用场景有待观望。
防止网站被利用
前面所说的,都是被攻击的网站如何做好防护。而非防止攻击的发生,CSRF的攻击可以来自:
- 攻击者自己的网站。
- 有文件上传漏洞的网站。
- 第三方论坛等用户内容。
- 被攻击网站自己的评论功能等。
对于来自黑客自己的网站,我们无法防护。但对其他情况,那么如何防止自己的网站被利用成为攻击的源头呢?
- 严格管理所有的上传接口,防止任何预期之外的上传内容(例如HTML)。
- 添加Header
X-Content-Type-Options: nosniff防止黑客上传HTML内容的资源(例如图片)被解析为网页。 - 对于用户上传的图片,进行转存或者校验。不要直接使用用户填写的图片链接。
- 当前用户打开其他用户填写的链接时,需告知风险(这也是很多论坛不允许直接在内容中发布外域链接的原因之一,不仅仅是为了用户留存,也有安全考虑)。
为什么 Cookie 无法防止 CSRF 攻击,而 Token 可以?
CSRF别忘了,本质上就是用你的身份去发送一些对你不友好的请求
进行 Session 认证的时候,我们一般使用
Cookie 来存储
SessionId,SessionId是当我们登陆后服务端生成的,然后装在
Cookie 中返回给客户端也就是浏览器,浏览器接收到 Cookie
后,会自动保存,同样服务端也会保存,服务端通过 Redis
或者位其他位置包括内存,记录保存着这个包含SessionId的
Cookie,这段服务端 → 客户端的流程叫做
SessionId 的下发
然后,客户端登录以后发送的每次请求都会带上这个包含
SessionId的Cookie,服务端通过这个
SessionId 来识别用户,这段客户端 → 服务端的流程叫做
SessionId 的回传
那么,CSRF
很明显就是别人拿到了你的信息,代替你的身份访问系统,而Session
认证系统中 Cookie 中的
SessionId的回传过程是由浏览器发送到服务端的,借助这个特性,攻击者就可以通过让用户误点攻击链接,达到攻击效果。
但是,我们使用 JWT 这种 Token
的话就不会存在这个问题,因为 JWT 的流转和 Session 完全不同
Token 的下发是用户登录验证通过后,服务端根据用户信息生成
JWT,然后直接把 JWT 字符串返回给前端, 前端接收到 JWT
后,手动存储到localStorage/sessionStorage,当然这里也可存
Cookie,但不会依赖浏览器自动携带
Token 的回传是用户后续发送请求时,前端手动从存储中取出 JWT,添加到请求头等其他位置,服务端接收到请求后,提取请求头中的 JWT,验证签名和有效性,解码出用户信息,完成身份认证。
这样,即使你点击了非法链接发送了请求到服务端,这个非法请求是不会携带
Token 的,因为浏览器不会自动携带本地存储中的 JWT,请求头无
Token,所以这个请求将是非法的。而Session 认证是浏览器自动携带包含
SessionId 的 Cookie 发送请求,才能让服务端误以为是用户本人操作
Spring Security 中 CSRF 防御实践
同步令牌模式
Spring Security 默认开启 CSRF 保护,采用的是同步令牌模式
基于 Session 的存储
由于这个我没有前后端一体的代码,只能简单说说了
服务器为每个用户的 Session 生成唯一的 CSRF Token,存储在服务器端的 Session 中,同时将 Token 嵌入到前端页面,然后
Spring Security 默认的 CSRF 防护会自动为每个 Session 生成 CSRF
Token,存储在HttpSession中,所以,不需要额外配置
默认情况下,CSRF 防护会拦截非 GET/HEAD/OPTIONS/TRACE的请求(如 POST/PUT/DELETE),要求携带 CSRF Token。
1 | package com.example.csrfsessiondemo.controller; |
使用 Thymeleaf 渲染页面,演示两种常见的 Token 携带方式:表单提交和AJAX 请求。
1 | <!DOCTYPE html> |
- Thymeleaf 注入 Token:Spring Security 会将 CSRF
Token
封装在模型变量
_csrf中,包含token(令牌值)、parameterName(默认是_csrf)、headerName(默认是X-CSRF-TOKEN)。 - 表单提交:通过隐藏域
<input type="hidden" name="_csrf" th:value="${_csrf.token}" />携带 Token,这是最基础的方式。 - AJAX 请求:可以将 Token
放在请求头(
X-CSRF-TOKEN)或请求参数(_csrf)中,Spring Security 都能识别。
基于 Cookie 的存储
首先,我们应该自己实现一个基于 Cookie 的存储的仓库,官方提供的基于 Cookie 存储 CSRF Token 的接口如下
如果你不自己实现,注意 SecurityConfig 中相关的内容的填写
1 |
|
随便挑了一个接口进行测试,你会发现
Cookie 中多了一个名为 MY-XSRF-TOKEN 的 Cookie,其值就是
CSRF 令牌
因为这个请求是 POST,我没携带 Token,所以我这边就是 401
双重提交 Cookie
在 Spring Security 中,双重提交 Cookie(Double Submit Cookie) 是一种常用的无状态 CSRF 防御机制,非常适合前后端分离的项目。
服务器生成一个随机 Token 写入 Cookie,前端在发送 POST/PUT
等写操作请求时,从 Cookie 中读取该 Token 并放入自定义 HTTP Header(如
X-XSRF-TOKEN)中。后端拦截器比对 Cookie 中的值与 Header
中的值,一致则放行。
所以说,需要配置Security 配置,而且关键在于配置
CookieCsrfTokenRepository。为了让前端 JavaScript 能够读取
Cookie,必须设置 withHttpOnlyFalse()。
1 | import org.springframework.context.annotation.Bean; |
而在 Spring Security 6 中,CSRF Token 默认是延迟加载的。为了确保前端在第一次访问时就能拿到 Cookie,我们需要一个 Filter 或 Controller 来显式触发它。而且别忘了在 Security中的配置器链中加入这个自己的过滤器
1 | import jakarta.servlet.FilterChain; |
当前端发起请求时,库会自动处理双重提交逻辑。
- 读取 Cookie:前端获取名为
XSRF-TOKEN的 Cookie 值。 - 设置 Header:在请求头中加入
X-XSRF-TOKEN。
而Axios 默认就支持这一机制,只需全局配置一下:
1 | import axios from 'axios'; |
我这边手上没有一个使用这样的前端例子,所以说,测试的时候就使用浏览器访问后端一个
GET 接口。查看浏览器控制台
Application -> Cookies,你会发现一个
XSRF-TOKEN。
使用 Postman 或前端代码发送 POST 请求,并在 Header 中带上:
Cookie: XSRF-TOKEN=你的值X-XSRF-TOKEN: 你的值
结果:返回 200 OK。
注意,使用这种方式,在生产环境中,请务必使用
CookieCsrfTokenRepository.withHttpOnlyFalse().setSecure(true)。如果不用
HTTPS,Token 容易被中间人窃取。
SameSite Cookie
使用 SameSite
属性,就是告诉浏览器,在跨站请求时,是否允许发送该 Cookie。
在 Spring 生态中,配置 Spring Security 生成的
Cookie(JSESSIONID 和
XSRF-TOKEN)主要通过以下两种方式:
配置 Session Cookie—
JSESSIONIDJSESSIONID是由 Servlet 容器(如 Tomcat)或 Spring Session 管理的。如果你没有使用 Spring Session,可以直接在
application.properties中配置:1
2
3
4
5
6# 设置为 Strict(最严格)或 Lax(推荐,兼顾体验与安全)
server.servlet.session.cookie.same-site=lax
# 建议同时开启,确保只在 HTTPS 下传输
server.servlet.session.cookie.secure=true
# 增强安全性,防止 JS 读取 Session ID
server.servlet.session.cookie.http-only=true如果你使用了 Spring Session(例如存储在 Redis 中),则需要配置一个
CookieSerializer的 Bean,来配置其中的 SameSite 属性1
2
3
4
5
6
7
8
9
public DefaultCookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName("JSESSIONID");
serializer.setCookiePath("/");
// 配置 SameSite
serializer.setSameSite("Lax");
return serializer;
}这样就修改了
配置 CSRF Cookie ——
XSRF-TOKEN这个 Token 是 Spring Security 在你开启相关 CSRF 设置之后专门生成的
所以,我们可以自定义 CSRF Token 存储库,来为 Spring Security 中的 Token 设置 samesite 属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class SecurityConfig {
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(cookieCsrfTokenRepository())
// 同样需要这个处理器来确保前端能获取 Token
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
)
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
/**
* 自定义 CSRF Token 存储库
*/
private CookieCsrfTokenRepository cookieCsrfTokenRepository() {
CookieCsrfTokenRepository repository = CookieCsrfTokenRepository.withHttpOnlyFalse();
// 关键点:通过 Customizer 设置 SameSite 属性
repository.setCookieCustomizer(cookieBuilder -> {
cookieBuilder.sameSite("Lax"); // 设置为 Lax 或 Strict
// cookieBuilder.secure(true); // 生产环境务必开启
});
return repository;
}
}
这个 samesite 其实是默认开启的,通常情况下,保持默认的 Lax 基本不会有问题,Strict 可能在重定向的时候失去登陆状态,而且 OAuth2 部分兼容存在问题







