资讯详情

资讯详情

AJAX请求为何成CSRF攻击跳板?完整防护方案实战解析

接入安全扫描报告的那一刻我对着屏幕上那一串“高危CSRF漏洞”愣住了。排查代码时发现问题几乎都出在同一种写法上没做任何防护的前端AJAX请求直接裸奔在公网上。修完之后我反复想明白一个事情——AJAX请求从来不是“跨域读不到响应所以安全”的避风港它恰恰是攻击者发起CSRF攻击时最顺手的跳板。这篇内容就是把这层逻辑、以及我踩过坑之后的完整修复方案拆开讲给你听。这篇文章适合所有写前端页面、维护SPA应用、或者负责接口设计的人。不管你是刚接触安全整改的前端新手还是已经调过不少接口问题的开发都能从中找到可直接落地的东西。1. 为什么AJAX会成为CSRF攻击的天然跳板1.1 浏览器自动携带的Cookie等于把门禁卡交给了陌生人CSRF的全称是跨站请求伪造。理解它的核心在于搞清楚浏览器和Cookie之间的运行机制。当用户登录了一个网站浏览器会把该站点域名下的会话Cookie存下来。之后用户在同源页面里发出的每一个请求浏览器都会自动把Cookie带上。问题就出在这个“自动”上。攻击者并不需要知道用户的Cookie内容他只需要让用户浏览器去访问一个恶意的URL浏览器就会替用户把请求和Cookie一并发送到目标服务器。服务器看到请求中的会话Cookie会认为这是合法用户的主动操作于是执行了改密码、转账、删数据等一系列敏感动作。AJAX在这个链条里扮演的角色就是把“诱导用户触发”变成“后台脚本自动触发”。攻击者可以在恶意页面里写一段极短的JavaScript代码用XMLHttpRequest或者fetch向目标站点的接口发起POST请求。只要该请求是浏览器允许的“简单请求”预检流程根本不会启动请求就直接到达服务器了。整个过程用户无感知甚至连页面跳转都不需要。这里有个很多开发者都会误解的地方跨域AJAX确实读不到响应因为同源策略会拦截跨域返回的数据。但请求本身已经打到服务器上了后端该执行的数据库操作、状态修改、支付回调一个都不会少。读不到响应只是“看不到结果”不是“请求没发生”。1.2 简单请求与预检保护你的代码不是WAF要理解为什么AJAX能成为跳板需要先弄明白CORS跨域资源共享是怎么工作的。当浏览器发起一个跨域请求时它会根据请求类型决定是否先发一个OPTIONS预检请求。只有预检通过浏览器才会发送真正的业务请求。但并非所有跨域请求都需要预检。如果请求满足以下条件它会被归类为“简单请求”方法限为GET、HEAD、POSTContent-Type限制在application/x-www-form-urlencoded、multipart/form-data或text/plain没有自定义请求头简单请求不会触发预检。攻击者用表单格式的POST或者text/plain格式的JSON提交AJAX直接就能把包发出去。哪怕目标网站没有开放CORS浏览器也能正常把请求送进服务器只是不允许JS读取响应而已。这就像一个陌生人进不了你家门但可以在门口按门铃、往门缝里塞纸条甚至对着门镜喊话。如果门内的智能门锁逻辑只认“门铃响了就自动开门”那攻击就成功了。实际攻击场景中还常用隐藏表单和图片标签来绕过JavaScript的限制。攻击页面可以放一个不可见的form用JavaScript自动submit到目标接口也可以利用img srchttps://目标站点/删除接口?id...触发GET请求。浏览器对这类资源请求同样会自动携带Cookie。所以就算AJAX被禁用了攻击者还有一堆备选方案。1.3 攻击链还原一张资料图、一段脚本接口就触发了为了更直观我把一次典型的CSRF攻击链拆开来看受害者登录目标站点浏览器存下了会话Cookie没有设置短有效期。用户访问了另一个站点的页面该页面被植入了恶意脚本。恶意脚本通过AJAX向目标站点的API接口发起POST请求请求头会自动带上目标域名的Cookie。目标服务器校验了Cookie但没校验请求的合法性来源认为操作来自登录用户执行了接口逻辑。接口执行完毕攻击者虽然读不到响应但业务数据已经被篡改或删除。这套攻击能成功的唯一关键在于服务器没有区分“用户主动发起的请求”和“攻击者伪造的请求”。要修补这个问题不能把希望寄托在“跨域会拦截响应”上而必须让每个写操作请求都携带一个攻击者无法预知、无法伪造的凭证。2. 防护思路的本质给每个请求一张防伪标签2.1 核心原则服务器要能验证“请求是谁授权的”CSRF防护没有任何银弹工具它靠的是在请求链路中插入服务器可验证的校验因子。这个因子必须满足两个条件攻击者无法从外部获得浏览器自动携带的内容如Cookie不能直接作为唯一校验依据原因很简单Cookie是浏览器自动带的攻击者的页面触发AJAX时Cookie也会跟着自动带上它无法证明“请求来自用户本人操作”。所以我们需要额外的东西来标记“这个请求是由本域页面内的JS脚本在用户授权下构造出来的”。一种常见做法是CSRF Token。服务器在用户访问页面时下发一个随机且唯一的Token前端页面在构造请求时把它放进请求头或表单字段。服务器校验Token匹配则放行不匹配则拒绝。由于恶意源页面无法读取目标域页面里的Token它构造的请求自然缺少这个关键的防伪标签。另一种思路是把Cookie本身加限制。通过SameSite属性告诉浏览器跨站请求不要自动携带Cookie。这样攻击页面发出的AJAX请求根本不会带上会话凭证服务器端Receiving到的就是一个无身份请求自然无法执行敏感操作。还有一种思路是校验请求来源。服务器检查HTTP请求中的Origin和Referer头确保请求来自信任的域名。这种方案不用改前端代码部署成本低但在浏览器兼容性和隐私设置上会有一些边界情况。2.2 CSRF Token的完整落地细节Token方案是目前应用最广、也被各大框架内置支持的做法。以Spring Security和Django为例它们都有现成的CSRF防护中间件默认给表单和AJAX请求注入Token。在前后端分离的架构下标准的Token使用流程是用户在页面加载时通过一个会话协商接口获取当前会话的CSRF Token。前端把Token保存在内存变量如localStorage有XSS风险并不推荐或Cookie副本中。前端每次发送写操作请求时把Token放进自定义请求头比如X-CSRF-Token。后端的过滤器对所有非安全方法POST、PUT、DELETE、PATCH校验Token不匹配就返回403。这里补充一个常被开发者忽略的细节不要把Token和Cookie混为一谈。如果Token放在Cookie里又被浏览器当作普通数据回传攻击者虽然读不到Cookie值但可以通过双重提交Double Submit模式来做校验。不过在条件允许的情况下我更推荐把它放在自定义请求头中配合后端校验这样攻击者构造跨域请求时浏览器不会自动附加自定义头原始攻击路径会被直接切断。2.3 SameSite属性给Cookie加上“身份标签”同样是围绕Cookie做文章SameSite属性是从源头上限制携带范围。取值有Strict、Lax和NoneSameSiteStrict所有跨站请求都不携带Cookie。严格模式能挡住绝大多数CSRF但用户体验也可能受影响。比如用户从外部链接进入页面时首次请求不带Cookie可能需要重新登录。SameSiteLax允许顶层导航如点击链接跳转携带Cookie但跨站的子资源请求、AJAX请求、表单提交都不携带。这是Chrome等浏览器默认采用的级别兼顾安全性和易用性。SameSiteNone必须配合Secure属性表示允许跨站携带Cookie。这通常用于单点登录、第三方支付回调等场景使用时要格外小心必须配合其他CSRF防护措施。设置SameSite之后攻击页面通过AJAX发往目标站点的请求若浏览器判定为跨站直接就不会附带会话Cookie。没有身份标识的请求后端多半会因未认证而拒绝执行。在设置SameSite时要注意如果你的站点被它方iframe嵌入比如登录弹窗、客服聊天则需要开启SameSiteNone和Secure。但这同时会把CSRF防护责任更多地转移到Token方案上因为跨站请求只要带着CookieCSRF风险就依然存在。2.4 Origin校验和Sec-Fetch-Site被很多人低估的方案除了Token和SameSite服务器端校验请求来源也是性价比很高的做法。Origin头是所有浏览器在发出跨域请求时都会自动附加的字段包含请求来源的协议、域名和端口。服务器端可以拿到Origin后与配置好的白名单域名做比对不在白名单内的直接拒绝。同源的页面之间发起请求时大多数浏览器也会带上Origin字段但个别低版本浏览器在GET请求中可能省略。所以实践中我会建议在Origin校验的同时对缺失Origin的请求做额外判断或者结合Referer来兜底。更高的版本中现代浏览器还支持Sec-Fetch-Site请求头它的值能直观告诉服务器当前请求的跨域级别same-origin表示同源same-site表示同站cross-site表示跨站none表示用户直接输入地址访问。服务器可以直接拒绝cross-site写操作。这个头由浏览器自动生成攻击者无法在页面脚本里伪造可靠性比Referer更高。不过Sec-Fetch-Site的浏览器兼容性还不是100%比较合理的策略是把Sec-Fetch-Site作为辅助校验手段Token和SameSite作为主防线。2.5 主流方案横向对比防护方案防护强度改造成本体验影响适用场景CSRF Token高中需前后端配合改造低前后端分离的应用、SPA、传统页面均可SameSite属性中高极低仅修改Cookie属性中Strict可能影响登录回跳传统多页应用、站内业务迭代快的平台Origin/Referer校验中低通常在后端网关统一处理低有统一API网关的团队适合快速加固Sec-Fetch-Site高兼容范围内低仅服务端判断逻辑无感知现代浏览器覆盖率高、用户更新积极的平台实际生产环境中我不会只选其中某一个而是会让它们并行工作。SameSite拉低大部分自动攻击的成功率Token确保即便遇到漏网跨站请求也无法通过校验Origin和Sec-Fetch-Site提供额外的一道兜底屏障。安全体系不是靠某一条规则单打独斗而是靠多层防线互相补位。3. 不同场景下的修复实操记录3.1 场景一传统表单提交接口的快速加固旧的内部管理系统登录后走Session前端用jQuery的$.ajax提交表单数据。接口清单里差不多一半是POST比如修改用户资料、提交审批、批量删除记录。第一步先给后端加一个CSRF Token生成和校验的过滤器。Token在用户登录成功后生成存入Session同时在返回给前端的响应中注入Cookie或者页面变量。前端所有AJAX请求通过beforeSend回调统一增加请求头$.ajaxSetup({ beforeSend: function(xhr, settings) { if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type)) { xhr.setRequestHeader(X-CSRF-Token, token); } } });Token从哪来这里有几个选择。最简单的是后端在渲染页面时直接输出到一个全局变量里。更稳妥的方案是单独暴露一个会话接口页面加载时异步获取Token再把它存在内存变量里。后端过滤器伪代码大致如下public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { HttpServletRequest httpReq (HttpServletRequest) request; String method httpReq.getMethod(); if (httpReq.getRequestURI().startsWith(/api/public)) { chain.doFilter(request, response); return; } if (!GET.equalsIgnoreCase(method) !HEAD.equalsIgnoreCase(method)) { String token httpReq.getHeader(X-CSRF-Token); String sessionToken (String) httpReq.getSession().getAttribute(CSRF_TOKEN); if (token null || !token.equals(sessionToken)) { ((HttpServletResponse) response).setStatus(403); return; } } chain.doFilter(request, response); }这套方案改造速度快效果立竿见影。要注意的点是Token校验必须在Session创建之后执行否则会出现误伤Token过期时间要和会话保持一致用户长时间挂机后提交请求会报403联系客服才排查出来这类事情很常见。3.2 场景二前后端分离的SPA应用单页应用和RESTful接口在防护逻辑上其实差不多但有几个容易踩坑的地方。Token的存储位置非常关键。如果存在localStorage里页面一旦存在XSS漏洞攻击者就能用脚本把Token掏走CSRF防护形同虚设。比较推荐的做法是把CSRF Token放在Server下发的一个单独的Cookie里但设置HttpOnly属性防止JS读取然后在AJAX请求时通过另一个普通Cookie的副本或者配合双重提交模式交给服务器校验。SPA场景更常见的稳定方案是后端登录成功后返回一个HttpOnly参数的会话Cookie同时通过接口返回一个CSRF Token串。前端把Token保存在一个模块级的内存变量中不写入任何持久化存储。每次发请求时从内存读取Token塞进请求头。刷新页面后重新从服务端获取Token。拿我接手过的某个H5页面来说它通过fetch调用后端接口。为了彻底避免手工加头时代不小心漏掉某些请求我封装了一个统一的请求函数const getCsrfToken () csrfToken; async function apiRequest(url, options {}) { const headers Object.assign({}, options.headers || {}); if (!/^(GET|HEAD)$/.test((options.method || GET).toUpperCase())) { headers[X-CSRF-Token] getCsrfToken(); } const res await fetch(url, Object.assign({}, options, { headers, credentials: include })); if (res.status 403) { // 令牌失效跳转重新获取Token或提示用户 refreshToken(); throw new Error(CSRF token校验失败); } return res.json(); }在这个场景里我也同时给Cookie加上了SameSiteLax。因为H5页面和API通常部署在不同域名它们之间属于跨站。SameSiteLax下用户通过其他网站链接进入H5的基础导航请求还能带Cookie而跨站的AJAX更新接口则不会自动带Cookie基本把攻击路径封死了。3.3 场景三文件上传和批量导入这类高危接口文件上传接口在很多系统里都被认为是CSRF高发区。原因倒不是攻击难度更高而是一旦接口被伪造攻击者可以上传恶意文件到服务器后面可能引发存储型XSS甚至提权危害被放大了。某次审计某公司内部无纸化办公系统时就发现批量导入接口完全裸奔。只要一个登录用户访问了恶意链接攻击者就可以借用这个用户的权限上传EXCEL或者任意类型文件到服务器。修复时除了加Token还把上传接口的请求方法限定为POST限制了Content-Type类型检查后端验证文件扩展名和MIME类型。同时给上传目录设置了禁止执行脚本的权限就算文件被传上去了也无法被当作代码执行。另外设计新接口时要保持一个原则任何会改变服务端状态的写操作都必须是POST、PUT、DELETE或PATCH不能使用GET触发修改。许多老系统把“删除记录”放在GET接口中攻击者构造一个图片标签就能触发删除这类接口必须先全部改掉。3.4 实战流程复盘一次跨部门的安全整改我记得当时整改分四步走盘点接口清单把线上所有暴露在公网且写操作无防护的接口列出来标记其访问路径、方法、对应页面。确定防护基准明确所有写请求必须携带有效CSRF TokenSet-Cookie响应统一设置SameSiteLax所有跨域写操作额外校验Origin白名单。前后端改造后端写统一的过滤器前端在公共请求库中统一注入Token请求头禁用全局AjaxSetup之外的裸请求调用。回归与验证对每个接口发起无Token请求、伪造跨站来源请求、正常业务请求三组用例确保只有正常请求能通过。整个过程最费时间的反而不是写代码而是找出代码库中那些绕过公共请求库、自己用原生XHR或者Fetch写的接口调用。这些漏网的裸请求才是安全扫描中被标记为CSRF高危的重点对象。4. 常见问题排查清单与深坑实录4.1 排查速查表现象可能原因处理建议AJAX请求返回403请求头未带Token或Token已过期检查beforeSend/fetch封装是否遗漏核对Token获取时机页面刚打开就请求失败Token接口和业务接口并发竞态在Token获取成功前阻止业务请求发出或加请求队列SameSite改成Lax后用户登录态频繁丢失站点的登录流程依赖跨站跳转携带Cookie评估将登录接口单独使用NoneSecure业务接口保持Lax跨域请求被浏览器拦截但后端日志显示已执行简单请求绕过预检直接到达服务器后端补Token和Origin校验禁止依赖浏览器拦截Token校验通过率很低时灵时不灵Token存储位置错误刷新后丢失或共享改为会话级或后端动态下发避免用过期的localStorageReferer校验在部分浏览器不生效浏览器隐私策略或禁用自动发送Referer换成Origin校验或Sec-Fetch-Site不单独依赖Referer4.2 深坑一Token总是失效排查了一整天有一次某平台上线后用户反馈“点按钮保存总失败”。排查发现后端返回403但前端的Token明明每次请求都打印出来了看起来是正确值。最后才发现问题出在单台服务器上的Session同步。用户第一次请求命中机器AToken存在A的Session下一次请求被负载均衡调度到机器BB的Session里根本没有对应的Token于是校验失败。这类问题的解法通常有几种一是把会话拉平用共享Session存储如Redis二是Token持有状态做一个独立的后端校验逻辑三是用无状态的双重提交Cookie方案让服务器不依赖Session也能完成校验。我当时把Session迁移到Redis后问题就消失了。如果你的系统刚好也在做分布式改造记得把Token校验方案纳入设计清单不然迟早会遇到这个坑。4.3 深坑二SameSite设置后登录态无缘无故丢失给某个老系统开启SameSiteStrict之后运营人员反馈从外部搜索点击进入系统时明明浏览器里之前已经登过录页面却显示未登录状态。原因就是Strict模式限制了顶层导航携带Cookie。用户通过搜索引擎直接跳转到系统首页时这个跳转被浏览器认定为跨站导航新页面发起的请求不会携带会话Cookie所以登录态就丢了。最终把Cookie调整为SameSiteLax业务正常了。Lax允许用户在跨站链接跳转时携带Cookie同时仍在AJAX和表单提交场景中阻止携带防护能力依然在线。如果你的系统有被外部链接跳入的需求Lax通常是更合适的选择。4.4 安全测试与自查方法线上业务不能只靠理论分析要有可复现的验证手段。推荐几类常用方法接口层测试用Burp Suite或者curl模拟一个有会话Cookie但无Token的POST请求看是否返回403。如果接口能正常执行说明防护仍有缺口。浏览器层面测试用一个完全不相关的域名构造一个跨站表单页登录状态下在该页面提交请求观察后端是否拒绝。用开发者工具Network面板看请求头是否携带了Cookie和CSRF Token。使用开源的CSRF PoC生成器这类工具能自动生成恶意页面HTML方便检查当前接口在跨站环境下是否仍然放行。我习惯把这类测试写进接口联调的自查列表里每次新接口上线前都跑一遍。说白了安全防护不能等到扫出来问题再补日常开发环节就得把习惯养成。5. 最后想说的几句实在话CSRF是一个很老的安全问题攻击原理教材里写了十几年但我每年都能在新项目里发现裸奔的写接口。原因多半不是开发者不知道CSRF而是默认“跨域读不到数据就很安全”低估了浏览器自动携带Cookie的杀伤力。我个人的习惯是所有写接口默认加一层防护并在代码评审里明确列出。具体用Token还是SameSite看系统架构和前端技术栈来定但“必须防护”这件事没有商量余地。遇到接口调试时不方便过Token校验的情况宁可临时关闭也要记得在提测前恢复这种事我吃过亏血的教训往往就藏在这些“先这样回头补”的缝隙里。如果这篇内容能帮你在下一次安全评审前把漏洞补齐那这篇文章就没有白写。真要干的时候记住一句话CSRF的修复没有高深的技术难的是把每一个写接口都平移到受保护的路径上漏掉一个攻击者就能从那个缺口绕进来。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →