Web安全实战:CSRF攻击原理与Token、SameSite、CORS组合防御策略
1. 项目概述:为什么CSRF攻击是Web安全的“隐形杀手”?
在Web开发和安全领域,CSRF(Cross-Site Request Forgery,跨站请求伪造)是一个老生常谈却又极易被忽视的漏洞。我见过太多项目,前端做得花里胡哨,后端逻辑也足够复杂,但偏偏在用户身份验证和请求来源校验上栽了跟头。简单来说,CSRF攻击就是攻击者利用用户当前已登录的浏览器会话,在用户不知情的情况下,向目标网站发起一个恶意请求。这个请求会“借用”用户的身份和权限,去执行一些非预期的操作,比如转账、修改密码、发表评论或者删除数据。
想象一下这个场景:你正在登录你的网上银行,这时你点开了一封看似无害的邮件里的链接,或者浏览了一个被攻击者控制的论坛页面。就在这个瞬间,一个隐藏的表单可能已经自动提交,向银行服务器发送了一条“向攻击者账户转账1000元”的指令。因为你的浏览器里存有有效的登录会话Cookie,银行服务器会认为这个请求就是你本人发起的,从而执行操作。整个过程你毫无察觉,攻击者却已得手。这就是CSRF的可怕之处——它不直接窃取你的密码或Cookie,而是“借刀杀人”,让服务器自己执行恶意操作。
为什么这个话题在今天依然重要?因为现代Web应用交互越来越复杂,单页面应用(SPA)、前后端分离架构盛行,但很多开发者对HTTP请求的安全边界理解并不深刻。默认情况下,浏览器会为每个请求自动带上对应域名的Cookie,这为CSRF攻击提供了天然的便利。无论是传统的表单提交,还是通过fetch、axios发起的AJAX请求,只要缺乏有效的来源验证,都可能成为攻击的入口点。接下来,我将结合我十多年的实战经验,从攻击原理、防御策略到具体代码实现,为你彻底拆解CSRF攻击的解决方法,让你不仅能理解理论,更能直接应用到项目中去。
2. 核心原理与攻击场景深度拆解
要防御CSRF,首先必须彻底理解它的攻击原理和依赖条件。很多防御措施失效,根源在于对攻击链的某个环节存在误解。
2.1 CSRF攻击成功的三个必要条件
根据OWASP的定义和我的实战观察,一次成功的CSRF攻击必须同时满足以下三个条件,缺一不可:
- 关键操作(State-Changing Action):攻击者诱导用户发起的请求,必须是一个能改变服务器状态的请求。这通常是
POST、PUT、DELETE或PATCH方法,用于执行如创建、更新、删除等操作。单纯的GET请求如果被设计为幂等的(即多次执行结果相同),如查询数据,通常不构成严重威胁,但最佳实践是避免用GET执行写操作。 - 基于Cookie的会话管理(Cookie-Based Session):目标网站完全或主要依赖Cookie(尤其是会话Cookie)来识别用户身份。当用户登录后,服务器会下发一个包含
Session ID的Cookie,浏览器在后续向该域名发起的所有请求中都会自动携带这个Cookie。攻击者无法直接读取或篡改这个Cookie(得益于同源策略),但他们可以诱导用户的浏览器去“使用”这个Cookie。 - 请求参数可预测(Predictable Request Parameters):攻击者能够预先知道或猜出执行目标操作所需的所有HTTP请求参数。例如,转账需要
recipient(收款人)和amount(金额)字段。如果这些参数是固定的,或者可以通过其他公开信息推断出来,攻击者就能轻易构造出恶意请求。
2.2 典型攻击向量与真实案例
攻击者会利用各种HTML元素和浏览器行为来发起伪造请求,远不止<form>一种方式。
案例一:自动提交的隐藏表单(最经典)这是最直观的攻击方式。攻击者在其控制的恶意网站上,嵌入一个隐藏的<form>,其action指向目标网站的关键接口(如银行的转账接口),并预先填好恶意参数。然后通过JavaScript在页面加载时自动提交表单。
<!-- 恶意网站 evil.com 上的页面 --> <body onload="document.forms[0].submit()"> <form action="https://bank.com/transfer" method="POST"> <input type="hidden" name="to" value="attacker_account" /> <input type="hidden" name="amount" value="10000" /> <!-- 可能还有其他隐藏字段,如CSRF Token,如果目标站没有防御,这里就不需要 --> </form> </body>当已登录bank.com的用户访问evil.com时,表单会自动提交。浏览器会向bank.com发起一个POST请求,并自动附上用户的会话Cookie。服务器看到合法的Cookie,便执行了转账。
案例二:利用<img>、<script>等标签的GET请求如果目标网站错误地使用GET请求来执行状态变更操作(这是一个严重的反模式),攻击将变得更加简单。攻击者甚至不需要表单,只需一个诱导用户点击的链接,或者一个会自动加载资源的标签。
<!-- 诱导用户点击的链接 --> <a href="https://bank.com/transfer?to=attacker&amount=1000">看这个有趣的猫咪视频!</a> <!-- 或者利用图片标签自动发起请求 --> <img src="https://bank.com/transfer?to=attacker&amount=1000" width="0" height="0" />当用户点击链接或页面加载了那个零尺寸的图片时,浏览器就会向目标URL发起一个GET请求,再次携带上用户的Cookie。
案例三:JSON API与复杂AJAX请求在现代前后端分离应用中,API通常使用application/json格式。很多人误以为这能天然防御CSRF,其实不然。如果服务器没有正确校验Content-Type或实施同源策略,攻击依然可能发生。攻击者可以通过构造一个恶意页面,使用JavaScript的fetchAPI向目标接口发送JSON格式的POST请求。
<script> fetch('https://api.bank.com/v1/transfer', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({to: 'attacker', amount: 1000}), credentials: 'include' // 关键:指示浏览器发送Cookie }); </script>这里的关键是credentials: 'include',它会让浏览器在跨域请求中也包含Cookie。如果目标API的CORS策略配置不当(例如设置了Access-Control-Allow-Origin: *且Access-Control-Allow-Credentials: true),这个攻击就会成功。
实操心得:在安全测试中,不要只测试表单。对于任何能改变状态的端点,无论是表单提交、AJAX调用还是GraphQL查询,都要用工具(如Burp Suite的CSRF PoC生成器)尝试构造跨域请求,测试其是否易受攻击。很多时候,开发团队会记得给表单加Token,却忘了给某个“内部使用”的JSON API加上校验。
3. 核心防御策略一:CSRF Token(同步令牌模式)
这是目前防御CSRF最主流、最有效的方法,被Django、Spring Security、Laravel等众多主流框架内置支持。其核心思想是:要求每个状态变更的请求都必须携带一个服务器生成的、不可预测的令牌(Token),该令牌与当前用户会话绑定。
3.1 Token的工作原理与生命周期
- 生成与存储:当用户访问包含表单的页面时(例如
GET /transfer),服务器端生成一个高强度的随机字符串作为CSRF Token。这个Token需要与当前用户的会话(Session)关联存储。通常,服务器会将其放入Session中,例如session[‘csrf_token’] = random_string。 - 下发与携带:服务器在渲染HTML页面时,将这个Token作为一个隐藏字段插入到表单中。
对于单页面应用(SPA),Token可以通过初始的HTML页面或一个专门的API端点(如<form action="/transfer" method="POST"> <input type="hidden" name="csrf_token" value="a1b2c3d4e5f6..."> <!-- 其他表单字段 --> </form>GET /api/csrf-token)下发,并由前端JavaScript存储(通常放在内存或非HttpOnly的Cookie中),在后续的AJAX请求中通过自定义HTTP头(如X-CSRF-TOKEN)携带。 - 验证与销毁:当用户提交表单或前端发起状态变更请求时,请求必须携带这个Token。服务器收到请求后,会从请求中提取Token(从表单字段或HTTP头),并与当前用户Session中存储的Token进行比对。只有两者一致,请求才被允许执行。为了增加安全性,Token应在每次验证后失效(使用一次即作废),或者采用时间戳+签名的方式设置较短的有效期。
3.2 服务端与前端实现细节
后端实现(以Node.js/Express为例):
const crypto = require('crypto'); const sessions = {}; // 简易的Session存储,生产环境请用Redis等 // 中间件:为每个会话生成并管理CSRF Token function csrfProtection(req, res, next) { let sessionId = req.cookies.sessionId; if (!sessionId || !sessions[sessionId]) { sessionId = crypto.randomBytes(16).toString('hex'); res.cookie('sessionId', sessionId, { httpOnly: true, secure: true }); sessions[sessionId] = {}; } req.session = sessions[sessionId]; // 如果Session中没有Token,则生成一个 if (!req.session.csrfToken) { req.session.csrfToken = crypto.randomBytes(32).toString('hex'); } // 将Token暴露给视图层(例如通过res.locals) res.locals.csrfToken = req.session.csrfToken; next(); } // 验证Token的中间件 function verifyCsrfToken(req, res, next) { const clientToken = req.body.csrf_token || req.headers['x-csrf-token']; const serverToken = req.session.csrfToken; if (!clientToken || clientToken !== serverToken) { return res.status(403).json({ error: 'Invalid CSRF token' }); } // 验证通过后,可以选择使旧Token失效并生成新Token(双重提交Cookie模式常用) // req.session.csrfToken = crypto.randomBytes(32).toString('hex'); next(); } app.use(csrfProtection); // 渲染带表单的页面 app.get('/transfer', (req, res) => { res.render('transfer-form', { csrfToken: res.locals.csrfToken }); }); // 处理表单提交 app.post('/transfer', verifyCsrfToken, (req, res) => { // 执行转账逻辑... res.send('Transfer successful'); });前端实现(传统多页应用):在服务端渲染的模板中,直接将Token作为隐藏字段输出。
<!-- transfer-form.ejs 模板 --> <form action="/transfer" method="POST"> <input type="hidden" name="csrf_token" value="<%= csrfToken %>"> 收款人: <input type="text" name="recipient"><br> 金额: <input type="number" name="amount"><br> <button type="submit">转账</button> </form>前端实现(SPA + AJAX):
- 首次加载页面时,从一个安全的端点(如
GET /api/csrf-token)获取Token。 - 将Token存储在内存或一个非
HttpOnly的Cookie中(注意,放在Cookie中时,需配合“双重提交Cookie”模式)。 - 在每个非幂等的AJAX请求(POST, PUT, DELETE等)的HTTP头中携带该Token。
// 假设从初始HTML的meta标签或API获取了Token let csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); // 使用fetch发起请求 fetch('/api/transfer', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken // 关键:在自定义头中携带Token }, body: JSON.stringify({ recipient: 'alice', amount: 100 }), credentials: 'include' // 如果需要发送认证Cookie });3.3 关键注意事项与避坑指南
- Token的强度与随机性:必须使用密码学安全的随机数生成器(如
crypto.randomBytes)来生成Token,长度建议至少32字节(64个十六进制字符)。切勿使用时间戳、用户ID等可预测的值。 - Token的存储与传输安全:
- 服务端存储:Token必须与用户会话紧密绑定。存储在服务器的Session中是最安全的方式。如果出于无状态架构考虑必须放在客户端,则应使用签名或加密的Token(如JWT格式),并在服务端验证签名,防止客户端篡改。
- 客户端传输:避免将Token放在URL中(
GET参数),因为URL可能被记录在浏览器历史、服务器日志或Referer头中,导致泄露。优先使用HTTP请求体(POST表单字段)或自定义HTTP头。
- Token的范围:通常为每个会话生成一个主Token即可。但对于极高安全要求的操作(如支付确认),可以考虑为每个表单或每次请求生成独立的Token。
- 不要依赖请求来源校验(Referer/Origin头):
Referer头可能被浏览器禁用或篡改,Origin头对于HTTPS到HTTP的请求不会发送。它们可以作为辅助校验手段,但绝不能作为唯一的防御措施。 - AJAX请求的特殊处理:确保你的CSRF保护中间件不仅能解析
application/x-www-form-urlencoded格式的请求体,也能解析application/json格式。许多框架的默认配置可能只处理前者。
踩过的坑:在一次项目审计中,我发现一个使用Express和
body-parser的应用,其CSRF中间件只在urlencoded解析之后才执行。而某个API端点只接受application/json,body-parser.json()中间件在CSRF中间件之后才被调用。这导致攻击者可以发送一个JSON格式的POST请求轻松绕过CSRF检查。教训:确保CSRF校验中间件在所有请求体解析中间件之后执行,并且能处理所有支持的内容类型。
4. 核心防御策略二:同源策略与Fetch元数据头
随着浏览器安全特性的演进,我们有了更多基于请求上下文(Context)的防御武器。这些方法的核心是让服务器能够区分“来自我自己网站的合法请求”和“来自其他网站的恶意请求”。
4.1 理解“简单请求”与“非简单请求”
这是CORS(跨源资源共享)规范中的核心概念,也是防御CSRF的重要基础。
- 简单请求(Simple Request):满足所有以下条件的请求:
- 方法为
GET,HEAD,POST之一。 - 仅允许人为设置以下集合中的请求头:
Accept,Accept-Language,Content-Language,Content-Type。 Content-Type的值仅限于application/x-www-form-urlencoded,multipart/form-data,text/plain三者之一。
- 方法为
- 非简单请求(Non-simple Request / Preflighted Request):不满足上述任一条件的请求,例如使用了
PUT、DELETE方法,或设置了自定义头(如X-CSRF-TOKEN),或Content-Type为application/json。
关键区别:对于简单请求,浏览器会直接发出请求,并在收到响应后根据CORS头决定是否将响应暴露给前端JS。这意味着CSRF攻击可以成功发起简单请求。对于非简单请求,浏览器会先发送一个OPTIONS方法的“预检请求”(Preflight Request)到服务器,询问是否允许跨域。只有服务器明确响应允许后,浏览器才会发送真正的请求。
因此,将你应用中的所有状态变更请求都设计为非简单请求,是防御CSRF的一道天然屏障。因为攻击者从第三方网站发起的非简单请求,会在预检阶段被浏览器拦截(如果服务器没有明确允许该第三方来源)。
4.2 使用Fetch元数据请求头(Fetch Metadata)
这是更现代、更精细的防御手段。浏览器在发起请求时,会自动添加一组以Sec-Fetch-*开头的请求头,统称为Fetch元数据。它们描述了请求的上下文信息,服务器可以据此判断请求是否可疑。
最常用的是Sec-Fetch-Site头,它直接告诉服务器这个请求的来源与目标的关系:
same-origin:同源请求。最安全。same-site:同站请求(eTLD+1相同,如a.example.com和b.example.com)。通常也较安全,但需注意子域名间的信任问题。cross-site:跨站请求。这是CSRF攻击的典型特征。none:由用户直接触发(如地址栏输入、书签)。
服务器端校验示例:
// Express 中间件:基于Fetch元数据拦截可疑请求 function checkFetchMetadata(req, res, next) { const secFetchSite = req.headers['sec-fetch-site']; const secFetchMode = req.headers['sec-fetch-mode']; // 只允许同源或同站的导航请求(navigate)或非简单的CORS请求 // 对于状态变更的POST/PUT/DELETE请求,严格一点,只允许同源 if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') { if (secFetchSite !== 'same-origin') { // 记录日志,发出警报 console.warn(`Potential CSRF attempt from: ${secFetchSite}, mode: ${secFetchMode}`); return res.status(403).json({ error: 'Cross-site request not allowed for this operation.' }); } } // 对于GET请求等,可以放宽到same-site next(); } app.use(checkFetchMetadata);4.3 结合CORS策略进行防御
正确配置CORS是防御通过AJAX发起的CSRF攻击的关键。核心原则是:严格限制Access-Control-Allow-Origin,并谨慎使用Access-Control-Allow-Credentials。
错误配置(导致漏洞):
// 危险!允许任意来源且允许携带凭证 app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', '*'); // 通配符 res.header('Access-Control-Allow-Credentials', 'true'); // 允许带Cookie next(); });这样的配置意味着evil.com可以轻易地用JavaScript向你的API发起带用户Cookie的请求。
安全配置示例:
const allowedOrigins = ['https://www.mytrustedapp.com', 'https://admin.mytrustedapp.com']; app.use((req, res, next) => { const origin = req.headers.origin; // 动态检查请求来源是否在白名单中 if (allowedOrigins.includes(origin)) { res.header('Access-Control-Allow-Origin', origin); // 精确设置,不用通配符 res.header('Access-Control-Allow-Credentials', 'true'); // 明确允许的HTTP方法 res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); // 明确允许的自定义头,用于CSRF Token res.header('Access-Control-Allow-Headers', 'Content-Type, X-CSRF-TOKEN'); } // 如果是预检请求,直接返回成功 if (req.method === 'OPTIONS') { return res.sendStatus(200); } next(); });实操心得:
Fetch Metadata头是浏览器自动添加的,无法被前端JavaScript伪造,因此可靠性很高。但它是一个较新的标准(Chrome 76+, Firefox 79+),在服务端校验时,一定要做好降级处理。可以将其作为一道强力补充防线,与CSRF Token结合使用,形成纵深防御。对于不支持该特性的旧版浏览器,则完全依赖Token进行校验。
5. 核心防御策略三:SameSite Cookie属性
Cookie的SameSite属性是浏览器提供的一种从源头减少CSRF风险的内置机制。它告诉浏览器在什么情况下应该随请求发送某个Cookie。
5.1 SameSite的三种模式
SameSite=Strict(严格模式):- 行为:Cookie仅在同站请求(即当前页面URL的站点与请求目标站点一致)时发送。这意味着从其他网站通过链接跳转过来,或者通过
<form>提交,只要来源不同站,Cookie都不会被发送。 - 安全性:最高。能有效阻止所有第三方上下文发起的CSRF攻击。
- 用户体验影响:最大。用户从搜索引擎结果页或邮件中的链接点击进入你的网站时,由于是跨站导航,Cookie不被发送,用户会显示为“未登录”状态,需要重新登录。这对于需要保持登录状态的网站(如社交网络、邮箱)可能不友好。
- 行为:Cookie仅在同站请求(即当前页面URL的站点与请求目标站点一致)时发送。这意味着从其他网站通过链接跳转过来,或者通过
SameSite=Lax(宽松模式,现代浏览器的默认值):- 行为:在跨站请求中,只有安全的顶层导航(top-level navigation)才会发送Cookie。安全的方法主要指
GET。这意味着:- 从其他网站点击链接(
<a href="...">)跳转过来,会带Cookie。 - 从其他网站通过
<form method="GET">提交(极少见),会带Cookie。 - 从其他网站通过
<form method="POST">提交、通过<img>、<script>、fetch等发起的请求,不会带Cookie。
- 从其他网站点击链接(
- 安全性:较高。能阻止大多数常见的CSRF攻击(因为攻击通常使用POST表单或自动加载资源)。
- 用户体验:较好。用户通过链接跳转能保持登录状态。
- 行为:在跨站请求中,只有安全的顶层导航(top-level navigation)才会发送Cookie。安全的方法主要指
SameSite=None:- 行为:Cookie在所有上下文中都会发送,即允许跨站使用。
- 安全性:无CSRF防护。必须与
Secure属性一起使用(即仅限HTTPS),否则浏览器会拒绝设置。 - 使用场景:主要用于需要被跨站嵌入的组件或服务,例如第三方登录插件、支付iframe、跨站AJAX调用的API。
5.2 服务端配置与实战策略
在设置会话Cookie时,明确指定SameSite属性。
Node.js/Express示例:
app.use(session({ secret: 'your-secret-key', cookie: { httpOnly: true, // 防止XSS读取 secure: process.env.NODE_ENV === 'production', // 生产环境强制HTTPS sameSite: 'lax', // 或 'strict',根据业务权衡 maxAge: 24 * 60 * 60 * 1000 // 1天 } }));策略建议:
- 对于主要的用户会话Cookie:优先设置为
SameSite=Lax。这是目前的最佳平衡点,既能防御大多数POST型CSRF攻击,又不影响用户通过链接正常访问网站。Chrome等浏览器已默认将未指定SameSite的Cookie视为Lax。 - 对于执行关键操作(如支付、修改密码)的专用Cookie或Token:可以考虑设置为
SameSite=Strict。将这些操作的端点与普通浏览页面分离,使用严格的Cookie。当用户从外部链接尝试访问这些端点时,由于Cookie不发送,他们会看到一个要求重新认证的页面,这反而提升了安全性。 - 避免使用
SameSite=None:除非你的服务明确需要被跨站嵌入。如果使用,务必同时设置Secure: true。
5.3 SameSite的局限性
依赖SameSite属性作为唯一的CSRF防御手段是危险的:
- 浏览器兼容性:虽然现代浏览器都已支持,但仍需考虑少量旧版本用户。
- “同站”不等于“同源”:
SameSite检查的是“站点”(eTLD+1),而不是“源”(协议+域名+端口)。这意味着app1.example.com和app2.example.com被视为同站。如果你不能完全信任所有子域名,那么子域名间的攻击(子域名接管漏洞)仍可能构成威胁。 - GET请求的CSRF:
SameSite=Lax允许跨站的GET请求携带Cookie。如果你的应用用GET请求执行状态变更(这是错误的设计),攻击者依然可以通过诱导用户点击链接(<a>标签)或加载图片(<img>)来实施CSRF。因此,绝对不要用GET方法执行写操作。
注意事项:在设置
SameSite=Strict时,务必进行充分的用户体验测试。特别是对于依赖第三方身份提供商(如OAuth登录回调)的场景,回调URL的请求可能因为Cookie未发送而导致登录失败。此时需要仔细设计流程,或对特定的登录回调端点使用Lax或None(并配合其他防御措施)。
6. 防御策略组合与纵深防御体系
在实际项目中,没有任何一种单一技术是银弹。最稳健的安全策略是建立纵深防御(Defense in Depth),层层设防,即使一层被突破,还有其他层提供保护。
6.1 推荐防御组合
对于大多数Web应用,我推荐的防御组合是:
- 第一道防线(核心):CSRF Token。为所有状态变更的请求(POST, PUT, DELETE, PATCH)实施CSRF Token校验。这是最可靠、最根本的防御。
- 第二道防线(加固):SameSite Cookie。将主要的会话Cookie设置为
SameSite=Lax。这能自动拦截大量来自第三方网站的、非用户主动触发的POST请求,为Token校验减轻压力,并作为Token机制失效时的后备。 - 第三道防线(监控与过滤):校验请求上下文。
- 检查
Origin/Referer头:虽然不完全可靠,但可以作为辅助校验。对于简单的同源应用,可以要求这两个头必须与目标站点匹配。注意处理头信息缺失的情况(如从HTTPS跳转到HTTP时Referer不发送,或用户隐私设置禁用Referer)。 - 利用Fetch元数据头(
Sec-Fetch-Site):在服务端中间件中检查该头,如果值为cross-site,则记录日志并发出警报,甚至可以结合风控系统直接拦截高风险请求。
- 检查
- 第四道防线(架构约束):安全的API设计。
- 禁用CORS或严格配置:对于不打算被第三方网站调用的API,不要设置宽松的CORS头。避免使用
Access-Control-Allow-Origin: *和Access-Control-Allow-Credentials: true的组合。 - 使用非简单请求:确保所有写操作的API端点,其请求都是“非简单请求”。可以通过要求
Content-Type: application/json或添加一个自定义请求头(如X-Requested-With: XMLHttpRequest)来实现。这能利用浏览器的预检机制阻挡一部分攻击。
- 禁用CORS或严格配置:对于不打算被第三方网站调用的API,不要设置宽松的CORS头。避免使用
6.2 针对不同架构的实施方案
传统服务端渲染(SSR)多页应用:
- 主要手段:CSRF Token(同步令牌模式)。在服务端渲染每个表单时嵌入Token,提交时验证。
- 辅助手段:
SameSite=Lax的会话Cookie。 - 实施要点:确保Token与用户会话绑定,并保证足够的随机性。
前后端分离的单页面应用(SPA):
- 主要手段:CSRF Token(通常通过自定义HTTP头传递,如
X-CSRF-TOKEN)。 - Token获取:SPA首次加载时,通过一个安全的
GET请求(如/api/csrf-token)从服务器获取Token。服务器可以将Token放在一个非HttpOnly的Cookie中(例如XSRF-TOKEN),前端JS读取后设置到后续请求的头部。这就是“双重提交Cookie”模式的一种变体:服务器下发Token到Cookie,前端读取后放到自定义头,服务器同时验证Cookie和头中的Token是否一致。 - 辅助手段:严格配置CORS,仅允许可信的前端域名。设置
SameSite=Lax或Strict的会话Cookie。 - 特别注意:确保获取Token的端点本身不受CSRF攻击(通常它是
GET方法,且不改变状态)。
基于Token的无状态API(如JWT):
- 如果应用完全使用JWT等Token进行认证,且Token不通过Cookie存储,而是通过
Authorization头传递,那么默认情况下不存在CSRF漏洞。因为浏览器不会自动在跨站请求中携带Authorization头。 - 但是,如果为了便利性,你将JWT也存放在了Cookie中(以便自动发送),那么CSRF风险就又回来了。此时必须为所有写操作实施CSRF Token或
SameSite=StrictCookie等防御措施。 - 最佳实践:对于无状态API,坚持将认证Token放在
Authorization头中,并由前端代码显式设置,不要依赖Cookie的自动发送机制。
6.3 常见问题排查与实战技巧
即使实施了防御,也可能因为细节问题导致漏洞。以下是我在渗透测试和代码审计中经常发现的问题:
问题1:Token验证逻辑存在缺陷
- 场景:服务器生成了Token,也进行了验证,但验证逻辑是“请求中只要有Token就行”,而不是“请求中的Token必须与Session中的Token匹配”。
- 排查:检查验证代码,确保是严格的字符串比对,并且比对的是服务器端存储的、与当前会话绑定的那个Token。
问题2:Token未绑定到具体用户或操作
- 场景:整个应用使用一个全局的、固定的CSRF Token。攻击者可以在自己的网站上获取到这个Token,然后用来构造对其他用户的攻击请求。
- 排查:确保每个用户会话都有独立的Token,并且对于高敏感操作,可以考虑使用一次性的Token。
问题3:Token在非HTTPS环境下传输
- 场景:网站未全站启用HTTPS,CSRF Token在明文HTTP请求中传输,可能被中间人攻击窃取。
- 解决:全站启用HTTPS,并在设置Cookie时使用
Secure属性。
问题4:CORS配置过于宽松
- 场景:
Access-Control-Allow-Origin: *且Access-Control-Allow-Credentials: true。这使得任何网站都可以发起带凭证的AJAX请求。 - 解决:永远不要同时使用这两个配置。如果需要跨域带凭证,必须明确指定
Access-Control-Allow-Origin为具体的、可信的来源,而不是通配符。
问题5:忽略了JSONP或其他古老接口
- 场景:应用为了兼容老客户端,保留了JSONP接口。JSONP通过
<script>标签加载,不受同源策略限制,也无法携带自定义头,因此传统的CSRF Token防御可能对其无效。 - 解决:对JSONP接口实施额外的校验,例如检查
Referer头,或要求请求中包含一个通过其他方式(如Cookie)下发的、难以猜测的令牌。更好的办法是逐步废弃JSONP,迁移到CORS。
快速检查清单:
- [ ] 所有状态变更的
POST/PUT/DELETE/PATCH端点是否都校验了CSRF Token? - [ ] Token是否足够随机(使用密码学安全的RNG生成)?
- [ ] Token是否与用户会话严格绑定?
- [ ] 会话Cookie是否设置了
SameSite=Lax或Strict属性? - [ ] CORS策略是否严格?是否避免了
Access-Control-Allow-Origin: *和Allow-Credentials: true的危险组合? - [ ] 是否完全杜绝了使用
GET方法执行写操作? - [ ] 是否对
application/json等非表单格式的请求也进行了CSRF校验? - [ ] 安全中间件的顺序是否正确?(Body Parser之后,路由处理之前)
防御CSRF是一个系统工程,需要开发、运维、测试各环节共同关注。将其纳入代码审查清单、自动化安全测试(如DAST/SAST工具扫描)和渗透测试的常规项目中,才能持续有效地保障应用安全。记住,安全不是一个功能,而是一种属性,需要贯穿于软件开发的整个生命周期。
