第 8 章 Web 安全深入:XSS / CSRF / CSP / 注入防护
前面几章把页面写出来了,但"本地能跑"和"敢上线"之间隔着一堵墙,叫安全。前端是唯一既接收用户输入、又直接执行代码的地方——用户输入的一句话,可能在你页面上摇身变成一段脚本,把别人的登录态偷走。后端同学最常见的误区是「安全是后端的事」,而 XSS 和 DOM 型 CSRF 恰恰是纯前端漏洞。这一章先把威胁模型讲清楚,再逐个拆开 XSS、CSRF、CSP 和注入。
论先问三个问题,防御手段自己就出来了
① 攻击者能往我的页面里塞东西吗?能塞进去、还被浏览器当代码执行 → XSS。
② 攻击者能让用户的浏览器"带着身份"替我发请求吗?Cookie 是自动带的,骗子网站也能触发 → CSRF。
③ 我的代码会把用户输入当"代码 / SQL / 命令"解释吗?会 → 注入(SQL、系统命令、原型链)。
三条线对应三句口诀:输出一律当数据、不当代码;跨站请求必须能证明是用户本人愿意的;输入一律当不可信数据。记住这三句,比背三十条规则管用。
XSS:三种形态,一种本质
XSS(Cross-Site Scripting,跨站脚本)的本质只有一句话:攻击者的字符串,被浏览器当成了代码去执行。按"载荷从哪来、谁把它送进 DOM"分三类。
| 类型 | 载荷从哪来 | 典型场景与特点 |
|---|---|---|
| 反射型 | URL 参数,服务端把参数原样"反射"回 HTML | 攻击者构造一条带 <script> 的链接发给受害者,一点就中。不落地、一次性,需要诱导点击。 |
| 存储型 | 数据库(评论、昵称、工单标题、上传的文件名) | 最危险:载荷存在服务器上,每个打开该页面的用户都会中招,等于白送一个长期后门。危害等级最高。 |
| DOM 型 | 不经过服务器,前端 JS 自己读了 location.hash 之类再写进 DOM |
后端怎么过滤都没用,漏洞就在前端代码里。React/Vue 项目照样会中,只要你动了 dangerouslySetInnerHTML 这类"逃生舱"。 |
DOM 型的经典翻车现场:把 URL 里的内容直接塞进 innerHTML
为什么"用了 React"也不代表安全
React 默认会把 {...} 插值做转义,所以 <p>{userInput}</p> 天生安全。但框架给你留了三个"逃生舱",一旦用了,转义就没了,责任全在你自己。
错觉一:「我把 <script> 过滤掉就行了」。不行。攻击者还有 <img onerror>、<svg onload>、<a href="javascript:">,再加上大小写、Unicode、HTML 实体各种变形,黑名单永远漏。只用白名单(允许什么),不用黑名单(禁止什么)。
错觉二:「前端转义过了,后端就不用管」。存储型 XSS 恰恰是后端把脏数据入库造成的。正确做法是存原文、按上下文转义输出:入库保留原样(否则用户改个昵称会变成 <b>),渲染在哪个上下文就在哪个上下文编码——HTML 正文、HTML 属性、URL、JS 字符串,四种上下文编码方式并不相同。
错觉三:「用了 React 就不会有 XSS」。上面三个逃生舱任意一个就能破功。而且 dangerouslySetInnerHTML 用多了,你等于在写 jQuery。
错觉四:「Cookie 设了 HttpOnly 就万事大吉」。HttpOnly 能挡住 document.cookie 偷 token,却挡不住攻击脚本用你的身份直接调接口(fetch('/api/transfer', { credentials:'include' }))——脚本跑在你的域名下,跟跨域毫无关系。
CSRF:Cookie 太听话,就成了软肋
CSRF(Cross-Site Request Forgery,跨站请求伪造)不需要偷你的 token,它利用浏览器"自动带 Cookie"的天性。你在 bank.com 登录着,Cookie 存在浏览器里;接着打开 evil.com,页面里藏着一个自动提交的表单 <form action="https://bank.com/transfer" method="POST">。浏览器一看是发给 bank.com 的请求,自动把 Cookie 附上——服务器只认 Cookie,就以为是你本人操作。
论为什么"加验证码 / 校验 Referer"不是正解
先看清 CSRF 成立的三个前提:① 操作用 Cookie 认身份;② 请求能被跨站构造(表单、img、fetch 都行);③ 参数可被攻击者预测。三条同时成立才构成漏洞,砍掉任意一条就防住了。
所以防御只有两大流派:让浏览器别自动带 Cookie(SameSite),或者让攻击者猜不到参数(CSRF Token)。验证码体验太差,不可能每个接口都上;Referer 校验在隐私模式、代理、小程序里可能直接为空,只能当辅助手段,不能当主力。
| 取值 | 行为 | 什么时候用 |
|---|---|---|
| Strict | 只要不是从本站发起的请求,一律不带 Cookie。用户从微信里点链接进来会"看起来没登录"。 | 安全要求极高的页面(支付确认、后台敏感操作),能忍受体验损失。 |
| Lax | 顶层导航的 GET 会带 Cookie;跨站的 POST / iframe / fetch 不带。刚好卡死最容易造出来的那个 CSRF 表单。 | 普通站点的默认选择,安全与兼容最平衡。 |
| None | 跨站也带,但必须同时加 Secure(只能走 HTTPS),否则浏览器直接拒绝这条 Cookie。 | 确实需要跨站带 Cookie:第三方嵌入、跨域 SSO、独立域名下的 API。 |
Cookie 的正确姿势(后端同学顺手对照)
CSRF Token:双提交(double submit)模式,前后端一起看
为什么加一个自定义头就够了?因为跨站表单没法设置自定义请求头;而如果改用 fetch 去加自定义头,浏览器会先发 OPTIONS 预检,跨站拿不到 CORS 放行就会失败。这就是"双提交"的巧妙之处:token 不需要服务端存储,只要两处值对得上即可。
CSP:转义漏了,还有最后一堵墙
CSP(Content Security Policy,内容安全策略)是浏览器提供的白名单机制:你提前告诉浏览器"这个页面只准执行哪些脚本、只准连哪些域名",名单外的全部拒绝执行。它是 XSS 的兜底——哪怕某处转义漏了,脚本也未必跑得起来。
| 指令 | 管什么 |
|---|---|
default-src | 没单独指定的资源类型的默认来源,通常写 'self'。 |
script-src | JS 从哪加载、内联脚本准不准跑。最关键的一条,XSS 主战场。 |
style-src | 样式来源。用内联 style 或 CSS-in-JS 时要留意 'unsafe-inline' 的代价。 |
img-src | 图片域名白名单,顺便堵住"用外链图片当数据外带通道"这条路。 |
connect-src | fetch / XHR / WebSocket 能连的地址,控制数据往外发。 |
frame-ancestors | 谁能用 iframe 嵌我(替代老的 X-Frame-Options),防点击劫持。 |
report-uri / report-to | 被拦下来的资源往哪上报,灰度期必备。 |
用 nonce 的严格 CSP(比 'unsafe-inline' 安全得多)
静态站点/框架侧配置示例:Vercel 的 vercel.json
坑一:直接上严格策略,页面全白。内联脚本、第三方统计、内联 style 全被拦。正确姿势是先发 Content-Security-Policy-Report-Only 跑两周,看报告里到底拦了谁,再逐条收敛、切换成强制模式。别拿生产环境当试验田。
坑二:为了省事加 'unsafe-inline'。这一加,XSS 防御基本回到解放前——攻击者注入的 <script> 也能跑了。内联非用不可时用 nonce 或 hash,不要用 unsafe-inline。
坑三:只写 default-src 就以为万事大吉。script-src 一旦单独出现,就不再继承 default-src;另外 frame-ancestors、base-uri、form-action 这几个不参与 default-src 回退,必须单独写。
注入:换个上下文,同一个病
XSS 是"把用户输入当 HTML/JS 执行",注入是同一个病换个上下文:当 SQL 执行、当 Shell 命令执行、当对象 key 执行。前端同学至少要懂它们的成因,才能和后端对齐防护方案,也才看得懂代码评审里的红线。
| 类型 | 错误写法 | 正确做法 |
|---|---|---|
| SQL 注入 | 拼字符串:"... WHERE email='" + email + "'",输入 ' OR 1=1 -- 就能绕过登录 |
参数化查询(预编译占位符)WHERE email = ?,值单独传;或用 ORM。让驱动负责转义,你永远不拼字符串。 |
| 命令注入 | exec("convert " + userPath),输入里带 ; rm -rf / 就执行了第二条命令 |
用 execFile / spawn 且不开 shell,参数以数组形式传;能不用子进程就别用。 |
| 原型链污染 | 把用户 JSON 深合并进对象:merge({}, JSON.parse(body)),body 里塞 __proto__ 就能改全局原型 |
过滤 __proto__ / constructor / prototype 这几个 key;用 Object.create(null) 装字典;用校验器(zod)剥掉多余字段。 |
| SSRF | 用户给的 URL 直接丢给服务端请求:fetch(req.body.url),填 http://169.254.169.254/ 就能读到云主机元数据 |
域名白名单;禁掉内网 IP 段与 localhost;只允许 https;限制重定向次数。 |
Node 侧的两个正例(前端同学看懂思路即可)
安全响应头全家桶:一次配齐
| 响应头 | 作用 |
|---|---|
Content-Security-Policy | 资源白名单,XSS 兜底。见上一节。 |
Strict-Transport-Security | HSTS:告诉浏览器"以后这个域名只走 HTTPS"(max-age=63072000; includeSubDomains),防降级劫持。 |
X-Content-Type-Options: nosniff | 禁止浏览器"猜"文件类型,防止把用户上传的 txt 当成脚本执行。 |
Referrer-Policy | 控制跳转时带多少来源信息,推荐 strict-origin-when-cross-origin,避免 URL 里的敏感参数泄漏给第三方。 |
Permissions-Policy | 关掉用不到的能力,如 camera=(), microphone=(), geolocation=()。 |
Cross-Origin-Opener-Policy / Cross-Origin-Embedder-Policy | 隔离跨源窗口与子资源,防 Spectre 类侧信道;开了之后才能用 SharedArrayBuffer。 |
Access-Control-Allow-Origin | CORS 白名单。别用 * 同时配 credentials: true,浏览器会直接拒绝;回显 Origin 时必须先校验域名白名单。 |
范三条能落地的开发习惯
① 输入只在一处校验:用 zod / class-validator 在系统边界把请求体收窄成确定类型,内部代码一律信任已经收窄过的数据,别到处做重复的"防御性判断"。
② 输出按上下文编码:写进 HTML 正文用 HTML 转义,写进属性、URL、JS 字符串各有各的编码规则;搞不清就交给框架的转义插值,别手写拼接。
③ 凭据只放 HttpOnly Cookie:localStorage 里的 token 对 XSS 是完全透明的。Cookie + HttpOnly + Secure + SameSite,再配 CSRF Token,是目前最稳的组合。
① XSS 三种形态(反射 / 存储 / DOM)本质相同:用户字符串被当成代码执行。防御靠上下文转义、textContent、DOMPurify 白名单。
② CSRF 靠浏览器自动带 Cookie生效,用 SameSite=Lax + CSRF Token 双提交两道锁;只在写请求上校验。
③ CSP 是兜底,用 nonce 而不是 unsafe-inline,上线前先 Report-Only 灰度。
④ 注入换上下文不换病:SQL 参数化、命令用数组、过滤 __proto__、URL 白名单防 SSRF。
1. 存储型 XSS 为什么比反射型危险?防御上有什么额外要求?
看答案
反射型要诱导用户点一条特制链接,一次性、传播靠钓鱼;存储型把载荷存进了数据库(评论、昵称),所有打开该页面的用户都会执行,等于常驻后门。额外要求:入库时不要提前做 HTML 转义(会污染原始数据、二次编辑变成乱码),而是在每个输出上下文分别转义或白名单清洗(DOMPurify),并配 CSP 兜底。
2. Cookie 设了 SameSite=Lax,是不是就不用做 CSRF Token 了?
看答案
不能只靠它。顶层导航的 GET 仍然会带 Cookie,所以任何用 GET 做写操作的接口照样被 CSRF;老浏览器不支持 SameSite;跨站 SSO 场景往往必须设 None。稳妥做法是 SameSite=Lax 打底 + 写请求校验 CSRF Token(或校验 Origin)+ 写操作一律用 POST/PUT/DELETE。
3. 为什么"双提交"CSRF Token 不需要服务端存储,却依然有效?
看答案
攻击者的跨站请求会自动带上 Cookie(里面有 token),但他读不到这个 Cookie 的值(同源策略限制),也没法给跨站表单设自定义请求头。所以他能带上 Cookie 里的 token,却填不对请求头里的 token,两边对不上就被拦。用 fetch 硬加自定义头会触发 CORS 预检,同样过不去。
4. CSP 里 'unsafe-inline' 和 nonce 有什么区别?
看答案
'unsafe-inline' 是"页面上所有内联脚本都放行",攻击者注入的 <script> 同样被放行,等于没防。nonce 是服务端每次响应生成一个随机值注入到自己写的 script 标签上,只有带对 nonce 的脚本才执行;攻击者无法预测下一次的 nonce,注入的脚本就不带 nonce、直接被拦。
5. 后端接口 GET /api/user/delete?id=1 用 fetch 调用,也不带任何表单,还会有 CSRF 风险吗?
看答案
会。CSRF 不依赖表单,一条 <img src="https://site.com/api/user/delete?id=1"> 就足以让浏览器带着 Cookie 发出 GET 请求,而且 SameSite=Lax 恰好允许顶层 GET 携带 Cookie。所以两条铁律:写操作必须用非 GET 方法,接口层再统一做鉴权 + CSRF 校验。