CORS与CSRF实战解析:从跨域请求到安全防御的完整指南

📅 2026/7/1 5:03:12
CORS与CSRF实战解析:从跨域请求到安全防御的完整指南
1. 项目概述从两个“拦路虎”说起搞Web开发尤其是前后端分离的项目有两个名字听起来很像、但本质完全不同的“拦路虎”经常把人绕晕CORS和CSRF。一个是浏览器为了安全给你设的“路障”另一个是攻击者为了窃取你身份而设的“陷阱”。很多文章要么只讲概念要么只给配置看完还是云里雾里真遇到浏览器控制台飘红报错“has been blocked by CORS policy”或者安全测试报告里出现“CSRF漏洞”时依然手足无措。这个项目的目的就是彻底终结这种模糊感。我们不满足于看文档而是要亲手搭建一个微型的、可复现的实验环境。通过这个环境你将能像调试普通业务逻辑一样亲眼看到CORS策略是如何在浏览器层面拦截请求的也能直观地理解一次成功的CSRF攻击是如何在用户毫无察觉的情况下完成的。更重要的是我们会剖析它们之间的联系为什么解决了CORS不等于解决了CSRF为什么某些场景下它们会“联手”出现理解了这些你不仅能正确配置Access-Control-Allow-Origin更能从架构层面设计出更安全的Web应用。2. 实验环境搭建与核心概念澄清在动手之前我们必须把两个概念的基础定义和实验的边界划清楚。这是后续所有实验能够正确进行的前提。2.1 核心角色定位CORS vs CSRF首先我们必须明确它们各自针对的问题域和“舞台”在哪里。CORS的全称是“跨源资源共享”。它的核心矛盾是“浏览器”与“服务器”之间的信任问题。当你的前端应用比如运行在http://localhost:3000试图通过JavaScript的fetch或XMLHttpRequest去请求另一个源比如http://api.example.com的资源时浏览器会主动介入检查。它会先询问目标服务器“嘿localhost:3000这个源来的小弟你允许他访问你的资源吗”这个询问可能是一个简单的请求头检查也可能是一个正式的OPTIONS预检请求。如果服务器响应头里没有明确说“我允许”浏览器就会毫不留情地阻断这个请求并在控制台抛出我们熟悉的CORS错误。所以CORS是浏览器强制执行的一套安全策略目的是防止恶意网站通过脚本随意读取另一个域下的敏感数据。CSRF的全称是“跨站请求伪造”。它的核心矛盾是“用户浏览器”与“服务器”之间的身份验证问题。假设你已经登录了银行网站bank.com浏览器里存着有效的登录凭证比如Session Cookie。此时你不小心访问了一个恶意网站evil.com。这个恶意网站里隐藏着一个自动提交的表单其action指向bank.com/transfer并填好了转账参数。由于浏览器会自动携带bank.com的Cookie这个伪造的请求在服务器看来完全就是一个来自已认证用户的合法请求从而可能成功执行转账操作。所以CSRF攻击利用了浏览器对特定网站如银行的自动身份认证机制诱骗用户的浏览器向目标网站发起一个非预期的请求。简单类比CORS像是小区门禁检查的是“来访者”前端JS有没有得到“业主”目标服务器的访问许可而CSRF像是有人伪造了你的门禁卡大摇大摆地进入了你家你的登录态。2.2 实验环境设计与工具选型为了直观展示我们需要模拟出三个关键角色受害者网站Vulnerable Site一个存在CSRF漏洞的简单Web应用用户在此登录。恶意网站Evil Site一个诱导用户访问的第三方网站用于发起CSRF攻击。API服务器API Server为受害者网站提供后端接口我们将在此观察CORS策略的影响。为了让实验足够轻量且聚焦我们选择Node.js Express作为后端框架。它足够简单能让我们快速搭建多个服务并精细控制HTTP响应头。前端我们直接用最原始的HTML和JavaScript避免框架带来的复杂度。你需要准备Node.js环境建议v16。一个代码编辑器。一个现代浏览器Chrome/Firefox用于观察开发者工具中的网络请求和报错。项目结构大致如下csrf-cors-demo/ ├── vulnerable-server/ # 受害者网站服务器 │ ├── server.js │ └── views/ ├── api-server/ # API服务器 │ └── server.js └── evil-site/ # 恶意网站 └── index.html我们将分别启动两个Node.js服务不同端口模拟不同源并用浏览器直接打开HTML文件来模拟恶意网站。3. 第一阶段亲手触发一个CORS错误让我们先让CORS错误“现身”。这个阶段的目标是不配置任何CORS头亲眼看到跨域请求被浏览器阻断。3.1 搭建无CORS配置的API服务器在api-server目录下创建server.jsconst express require(express); const app express(); const PORT 3001; // 一个非常简单的API返回一些数据 app.get(/api/data, (req, res) { // 注意这里故意不设置任何CORS相关的响应头 res.json({ message: 敏感数据来自 API Server 3001 }); }); // 一个接受POST请求的API模拟修改操作 app.post(/api/update, express.json(), (req, res) { console.log(API Server: 收到更新请求数据为:, req.body); // 同样不设置CORS头 res.json({ status: success, data: req.body }); }); app.listen(PORT, () { console.log(API Server 运行在 http://localhost:${PORT}); });这个服务器监听3001端口提供了两个端点但关键点在于它没有设置Access-Control-Allow-Origin等任何CORS响应头。3.2 创建前端页面发起跨域请求在vulnerable-server目录下我们创建一个简单的服务器和前端页面。先创建server.jsconst express require(express); const path require(path); const app express(); const PORT 3000; app.use(express.static(views)); // 托管静态文件 app.get(/, (req, res) { res.sendFile(path.join(__dirname, views, index.html)); }); app.listen(PORT, () { console.log(受害者网站运行在 http://localhost:${PORT}); });然后在views目录下创建index.html!DOCTYPE html html head title受害者网站 (localhost:3000)/title /head body h1我是受害者网站 (源: http://localhost:3000)/h1 button onclickfetchData()获取API数据 (GET)/button button onclickupdateData()更新数据 (POST)/button div idresult/div script const apiBase http://localhost:3001; // 不同端口不同源 const resultDiv document.getElementById(result); function fetchData() { resultDiv.innerHTML 请求中...; fetch(${apiBase}/api/data) .then(response response.json()) .then(data { resultDiv.innerHTML 成功: ${JSON.stringify(data)}; }) .catch(error { // 这里会捕获到由CORS策略导致的错误 resultDiv.innerHTML span stylecolor:red;失败: ${error.message}/span; console.error(CORS错误详情:, error); }); } function updateData() { resultDiv.innerHTML 更新中...; fetch(${apiBase}/api/update, { method: POST, headers: { Content-Type: application/json, }, body: JSON.stringify({ action: modify, user: testUser }) }) .then(response response.json()) .then(data { resultDiv.innerHTML 更新成功: ${JSON.stringify(data)}; }) .catch(error { resultDiv.innerHTML span stylecolor:red;更新失败: ${error.message}/span; console.error(CORS错误详情:, error); }); } /script /body /html3.3 运行并观察现象打开两个终端。在第一个终端进入api-server目录运行node server.js。在第二个终端进入vulnerable-server目录运行node server.js。用浏览器访问http://localhost:3000。点击“获取API数据 (GET)”按钮。你将立刻在浏览器控制台F12打开的“网络”选项卡和“控制台”选项卡中看到错误Access to fetch at http://localhost:3001/api/data from origin http://localhost:3000 has been blocked by CORS policy: No Access-Control-Allow-Origin header is present on the requested resource.同时resultDiv会显示红色的失败信息。但如果你直接访问http://localhost:3001/api/data或者用curl、Postman等工具请求这个接口你会发现接口是通的数据能正常返回。这完美证明了CORS是浏览器强加的限制服务器本身并没有拒绝请求。注意对于某些“简单请求”如GET/HEAD/POST且Content-Type为application/x-www-form-urlencoded,multipart/form-data,text/plain浏览器会直接发出请求然后根据响应头决定是否将结果暴露给JS。而对于“非简单请求”如使用了application/json的POST或自定义头浏览器会先发一个OPTIONS方法的“预检请求”。我们上面的POST请求因为Content-Type: application/json就会触发预检。你可以在网络面板中看到这个OPTIONS请求它同样会因为缺少CORS头而失败导致真正的POST请求根本不会发出。4. 第二阶段正确配置CORS并理解其机制现在我们来解决CORS问题并深入理解其配置细节。4.1 为API服务器添加CORS支持修改api-server/server.js使用cors中间件是最方便的方式const express require(express); const cors require(cors); // 引入cors包 const app express(); const PORT 3001; // 配置CORS中间件 // 方案1允许所有来源极度危险仅用于演示或内部工具 // app.use(cors()); // 方案2允许特定来源生产环境推荐 const corsOptions { origin: http://localhost:3000, // 只允许来自3000端口的请求 optionsSuccessStatus: 200 // 对于某些旧版浏览器 }; app.use(cors(corsOptions)); // 方案3手动设置响应头理解原理 // app.use((req, res, next) { // // 检查请求头中的Origin动态设置或白名单校验 // const allowedOrigins [http://localhost:3000, https://myapp.com]; // const origin req.headers.origin; // if (allowedOrigins.includes(origin)) { // res.setHeader(Access-Control-Allow-Origin, origin); // } // // 对于需要携带凭证如Cookie的请求必须设置此项且不能为通配符‘*’ // // res.setHeader(Access-Control-Allow-Credentials, true); // // 允许的HTTP方法 // res.setHeader(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS); // // 允许的自定义请求头 // res.setHeader(Access-Control-Allow-Headers, Content-Type, Authorization); // // 预检请求缓存时间秒 // res.setHeader(Access-Control-Max-Age, 86400); // next(); // }); app.get(/api/data, (req, res) { res.json({ message: 敏感数据来自 API Server 3001现在CORS已允许 }); }); app.post(/api/update, express.json(), (req, res) { console.log(API Server: 收到更新请求数据为:, req.body); res.json({ status: success, data: req.body }); }); app.listen(PORT, () { console.log(API Server (已启用CORS) 运行在 http://localhost:${PORT}); });重启API服务器后再次回到http://localhost:3000点击按钮。你会发现GET和POST请求都成功了浏览器控制台不再报错网络面板中可以看到OPTIONS预检请求返回了204 No Content并且响应头中包含了我们配置的Access-Control-Allow-Origin: http://localhost:3000。4.2 关键配置项解析与避坑指南Access-Control-Allow-Origin这是最核心的头。设置为*表示允许任何源但当请求需要携带凭证credentials: include时不能使用*必须指定明确的源。Access-Control-Allow-Credentials如果需要前端在跨域请求中携带Cookie或HTTP认证信息必须在fetch中设置credentials: include并且服务器响应头必须包含Access-Control-Allow-Credentials: true。同时Access-Control-Allow-Origin不能为*。Access-Control-Allow-Methods指定允许的HTTP方法。对于预检请求浏览器会检查实际请求方法是否在此列表中。Access-Control-Allow-Headers指定允许的自定义请求头。如果你在前端设置了Authorization头这里就需要包含它。Access-Control-Max-Age指定预检请求的结果可以被缓存多久秒。合理设置可以减少不必要的预检请求提升性能。实操心得很多同学在本地开发联调时喜欢用浏览器插件如CORS Everywhere一键禁用CORS。这虽然方便但会掩盖潜在问题。强烈建议在开发环境就配置好正确的CORS这样能尽早发现前端或服务端配置的不一致。插件无效的情况往往是因为请求模式复杂如涉及凭证、自定义头插件无法完全模拟正确的服务端响应。5. 第三阶段构造一个真实的CSRF攻击场景解决了CORS我们的前端可以自由调用API了。但这恰恰是另一个安全故事的开始CSRF。让我们构造一个攻击场景。5.1 强化受害者网站添加会话认证首先我们让受害者网站localhost:3000的更新操作需要登录态。修改vulnerable-server/server.js添加一个简单的会话模拟const express require(express); const path require(path); const app express(); const PORT 3000; app.use(express.static(views)); app.use(express.urlencoded({ extended: true })); // 模拟一个“登录”接口设置一个简单的Cookie作为登录凭证 app.post(/login, (req, res) { // 实际项目中这里会验证用户名密码这里我们简单模拟 res.cookie(sessionId, fake_session_id_123456, { httpOnly: true }); res.redirect(/); }); // 一个需要登录态才能访问的“敏感操作”页面 app.get(/profile, (req, res) { // 简单检查Cookie模拟登录验证 if (req.headers.cookie req.headers.cookie.includes(sessionId)) { res.sendFile(path.join(__dirname, views, profile.html)); } else { res.redirect(/); } }); app.get(/, (req, res) { res.sendFile(path.join(__dirname, views, index-with-auth.html)); }); app.listen(PORT, () { console.log(受害者网站 (带登录) 运行在 http://localhost:3000); });同时更新前端页面index-with-auth.html加入登录表单和携带Cookie的请求!DOCTYPE html html headtitle受害者网站 - 需登录/title/head body h1受害者网站 (localhost:3000)/h1 div idauth-area form action/login methodPOST button typesubmit模拟登录设置Cookie/button /form pa href/profile进入个人中心需登录/a/p /div hr div p以下操作会携带Cookie请求API/p button onclickupdateDataWithAuth()更新我的数据带认证/button div idresult/div /div script const apiBase http://localhost:3001; const resultDiv document.getElementById(result); function updateDataWithAuth() { resultDiv.innerHTML 请求中...; fetch(${apiBase}/api/update, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ action: changeEmail, newEmail: newvictim.com }), credentials: include // 关键携带Cookie }) .then(response response.json()) .then(data { resultDiv.innerHTML 成功: ${JSON.stringify(data)}; }) .catch(error { resultDiv.innerHTML span stylecolor:red;失败: ${error}/span; }); } /script /body /html另外创建profile.html展示用户登录后的状态。5.2 修改API服务器以识别Cookie为了让攻击更真实我们修改API服务器让它“识别”来自受害者网站的认证请求通过Cookie。修改api-server/server.js中的/api/update接口app.post(/api/update, express.json(), (req, res) { const sessionCookie req.headers.cookie; let user 匿名用户; // 简单解析Cookie模拟验证 if (sessionCookie sessionCookie.includes(fake_session_id)) { user 已登录用户 (受害者); } console.log(API Server: 收到来自【${user}】的更新请求数据为:, req.body); // 注意这里仍然配置了CORS允许来自localhost:3000的请求 res.json({ status: success, data: req.body, performedBy: user }); });重启API服务器并确保CORS配置仍然允许origin: http://localhost:3000。5.3 创建恶意网站并发动攻击现在在另一个端口或直接打开本地HTML文件创建一个恶意网站。创建evil-site/index.html!DOCTYPE html html head title抽奖活动点击就送/title stylebody { padding: 20px; font-family: sans-serif; }/style /head body h1 恭喜您中奖了 /h1 p请点击下方按钮领取您的百万大奖/p !-- 隐藏的CSRF攻击表单 -- form idcsrfForm actionhttp://localhost:3001/api/update methodPOST styledisplay: none; input typehidden nameaction valuetransferMoney / input typehidden nameamount value10000 / input typehidden nametoAccount valuehacker_account_666 / /form button onclicklaunchAttack()点击领取大奖/button script function launchAttack() { alert(正在为您领取奖金...); // 自动提交隐藏的表单 document.getElementById(csrfForm).submit(); } // 或者页面加载时自动发起攻击更隐蔽 // window.onload function() { // fetch(http://localhost:3001/api/update, { // method: POST, // headers: { Content-Type: application/json }, // body: JSON.stringify({ action: transferMoney, amount: 10000, toAccount: hacker_account_666 }) // // 注意这里没有 credentials: include因为攻击来自第三方网站无法携带受害者网站的Cookie。 // // 但如果是简单的表单提交浏览器会自动携带Cookie // }); // }; /script psmall这是一个模拟的恶意网站仅用于安全演示/small/p /body /html关键点这个恶意网站位于完全不同的源比如你用浏览器直接打开这个file://路径的HTML或者把它放在另一个端口的服务器上如http://evil.com:8080。它包含一个隐藏的表单其action直接指向我们受害者网站所依赖的API接口http://localhost:3001/api/update。5.4 模拟攻击流程与观察结果用户首先访问http://localhost:3000点击“模拟登录”按钮。这会设置一个名为sessionId的Cookie。用户在没有退出登录即Cookie仍然有效的情况下被诱导访问了恶意网站直接双击打开evil-site/index.html。用户点击了恶意网站上的“点击领取大奖”按钮。脚本自动提交了隐藏表单向http://localhost:3001/api/update发起了一个POST请求。此时观察API服务器的控制台输出API Server: 收到来自【已登录用户 (受害者)】的更新请求数据为: { action: transferMoney, amount: 10000, toAccount: hacker_account_666 }攻击成功了尽管这个请求来自恶意网站evil.com但浏览器在发送请求到localhost:3001时自动携带了之前为localhost:3000设置的Cookie。因为API服务器的CORS策略允许来自localhost:3000的请求并且它只通过Cookie来“识别”用户没有验证这个请求是否真正来自localhost:3000的前端应用所以它把这个伪造的请求当成了合法请求执行了注意事项这个实验成功的关键在于我们模拟的API接口是“简单”的POST如果表单的enctype是默认的application/x-www-form-urlencoded它不会触发CORS预检。即使触发预检只要服务器配置了允许任意源*或包含了恶意网站的源预检也会通过。CSRF攻击不依赖于JavaScript它利用的是浏览器自动携带Cookie的机制。这也是为什么像img src”http://bank.com/transfer?tohackeramount1000″这样的GET请求也能构成CSRF攻击。6. 第四阶段剖析CORS与CSRF的联系与根本区别通过上面的实验我们可以清晰地总结联系都涉及“跨源/跨站”两者都发生在不同源站点之间。CORS可能影响CSRF的利用方式如果API服务器配置了严格的CORS策略如只允许特定的Origin那么来自恶意网站evil.com的AJAX请求使用fetch或XMLHttpRequest会被浏览器直接阻止这在一定程度上增加了利用CSRF的难度。攻击者必须使用不会触发CORS预检的方式如自动提交表单form提交、img标签、script标签等。所以严格的CORS策略是深度防御的一环但不能替代专门的CSRF防护。根本区别特性CORSCSRF安全目标保护资源提供方服务器的数据防止被恶意网站的前端脚本窃取。保护资源所有者用户的操作防止被恶意网站利用其已登录的身份执行非预期操作。执行者浏览器根据服务器响应头决定是否放行。攻击者构造恶意请求利用浏览器的自动行为如发送Cookie。触发条件由前端JavaScript发起的跨域HTTP请求触发。由用户访问恶意网站触发攻击请求可由多种方式发起表单、图片、脚本等不一定是JS。防护位置服务器端通过设置正确的HTTP响应头如Access-Control-Allow-Origin。服务器端需要验证请求的意图是否来自合法的自家页面如使用CSRF Token、同站Cookie等。错误表现浏览器控制台出现CORS策略错误请求被浏览器阻止前端JS收到错误。请求通常能成功发送到服务器并被执行用户和前端可能毫无感知需通过服务器日志或业务异常发现。核心结论配置了CORS甚至配置了允许携带凭证Access-Control-Allow-Credentials: true你的网站依然可能遭受CSRF攻击。因为CORS管的是“谁家的脚本能读我的数据”而CSRF利用的是“浏览器自动以用户身份发请求”。7. 如何有效防御CSRF攻击理解了区别防御措施就清晰了。CORS头对防御CSRF基本无效我们必须采用专门的手段CSRF Token最常用、最有效原理服务器在渲染页面时生成一个随机、不可预测的Token嵌入到表单或Meta标签中。前端在提交请求时必须携带这个Token通常放在请求头或请求体中。服务器在处理请求前校验Token的有效性。实操对于我们的实验可以在profile.html页面中由服务器生成一个Token并嵌入。前端fetch请求时将其放在headers里如X-CSRF-Token。API服务器在处理/api/update时必须校验这个Token。为什么有效恶意网站无法提前知晓或获取到这个与当前用户会话绑定的随机Token因此无法构造出合法的请求。同站CookieSameSite Cookie Attribute原理设置Cookie的SameSite属性。Strict完全禁止第三方上下文携带CookieLax默认在安全顶级导航如链接点击时允许其他情况如POST表单、iframe禁止None允许跨站携带但必须同时设置Secure仅HTTPS。实操在设置登录Cookie时res.cookie(‘sessionId’, ‘value’, { httpOnly: true, sameSite: ‘lax’ })。现代浏览器默认即为Lax能防御大多数CSRF。注意SameSiteLax对于GET类操作如图片、脚本加载的防护较弱且需要浏览器支持。验证请求来源Origin/Referer Header原理服务器检查请求头中的Origin或Referer字段判断是否来自预期的源自家网站。局限性Referer头可能被某些浏览器隐私设置或防火墙过滤掉且在某些场景下如从HTTPS跳到HTTP不会发送。Origin头对于简单的表单提交可能不存在。通常作为辅助手段。在我们的实验环境中实施CSRF Token防御在受害者网站服务器生成Token并传给前端。前端在调用/api/update时将Token放入请求头如X-CSRF-Token。修改API服务器的/api/update端点除了检查Cookie还必须校验请求头中的Token是否有效且与用户会话匹配。此时恶意网站发起的请求由于没有正确的Token会被服务器拒绝。经过这样的改造即使CORS配置允许来自任意源CSRF攻击也无法成功因为缺少了关键的Token。这才是完整的安全链条。8. 常见问题与排查技巧实录在实际开发和调试中你会遇到各种各样的问题。这里记录一些高频问题和排查思路Q1为什么我明明配置了CORS中间件前端还是报CORS错误检查顺序确保CORS中间件在其他中间件尤其是路由处理之前注册。如果请求先被路由处理并返回了响应CORS中间件可能就没机会添加响应头。检查路径某些静态文件路由或特定的API路径可能没有经过配置了CORS的中间件。检查预检请求对于非简单请求浏览器会先发OPTIONS请求。你的服务器必须能正确处理OPTIONS方法。cors中间件会自动处理如果手动设置头需要单独处理app.options(‘*’, …)。查看响应头在浏览器开发者工具的“网络”选项卡中仔细查看出错请求的响应头确认Access-Control-Allow-Origin等头是否存在且值正确。Q2使用了credentials: ‘include’但Cookie还是没带上服务器头配置确保服务器响应头包含Access-Control-Allow-Credentials: true。Origin不能为*当使用credentials: ‘include’时Access-Control-Allow-Origin必须是一个明确的源如http://localhost:3000不能是通配符*。Cookie属性确保服务器设置的Cookie没有不安全的SameSite策略例如在非HTTPS环境下设置了SameSiteNone会导致Cookie被阻止。本地开发时可以暂时不设置SameSite或设置为Lax。Q3在类似DVWA、Pikachu这样的靶场中做CSRF实验为什么有时候攻击不成功靶场防护很多现代靶场默认开启了CSRF防护如使用了Token。你需要先找到关闭防护的开关如DVWA的安全等级调到Low或者查看源码了解其防护机制。请求方式确认靶场接收的是GET还是POST请求。GET请求的CSRF通常用img标签POST请求用隐藏表单。会话一致性确保你的攻击页面和受害者页面访问的是同一个靶场应用且你的浏览器保持着有效的会话Cookie。有时靶场会有会话超时机制。Q4线上环境CORS和CSRF该如何配置CORS绝不使用origin: ‘*’除非是公开的、无需认证的只读API如天气API。根据前端部署的域名精确配置origin白名单。根据需求谨慎配置Access-Control-Allow-Methods和Access-Control-Allow-Headers遵循最小权限原则。生产环境务必使用HTTPS。CSRF首选CSRF Token并将其与用户会话绑定。对于单页应用(SPA)可以将Token存储在内存或安全的Cookie中并在每个非幂等的请求中携带。设置Cookie的SameSite属性为Strict或Lax。对于关键操作如转账、改密可以增加二次验证如短信、密码。通过这一系列从搭建、触发、解决到防御的亲手实验CORS和CSRF不再是两个模糊的概念。你看到了浏览器如何拦截请求也看到了攻击如何悄无声息地发生。最重要的是你明白了它们一个管“读权限”一个管“写权限”二者必须双管齐下才能构建起前端与后端之间坚实的安全桥梁。下次再看到控制台里的CORS错误或者安全扫描报告里的CSRF漏洞你就能胸有成竹地知道问题在哪以及该如何解决了。