1. 为什么前端安全总是“看起来简单,出事却很大”
Web安全这个话题,很多做前端或者全栈的朋友一开始都不太当回事。我刚入行那会儿也一样,觉得页面能跑、接口能通、样式没崩,任务就算完成了。直到有一次,我们内部做安全自查,发现一个用户评论模块可以直接把一段脚本塞进数据库,然后在所有访问者的浏览器里执行——那一刻我才真正意识到,XSS和CSRF这类问题不是“理论上的风险”,而是随时可能把整个系统拖下水的定时炸弹。
这篇文章想聊的,就是Web安全里最经典、也最容易被忽视的两个话题:XSS(跨站脚本攻击)和CSRF(跨站请求伪造)。我不会只给你背概念,而是从攻击原理、实际场景、代码层面的复现,到防御方案的取舍,一步步拆开讲。适合谁看?前端开发、后端开发、测试人员,以及任何需要对自己项目安全负责的人。哪怕你之前完全没接触过安全,看完也能在自己的项目里落地一套基础防护。
先说一个我自己的判断:大部分Web安全漏洞,不是因为技术太难,而是因为开发者默认“用户不会那么坏”。这个默认假设,就是所有问题的起点。
2. XSS到底是怎么被塞进页面的
2.1 从一次“无害”的评论功能说起
假设你做了一个博客系统,用户可以在文章下面留言。前端拿到留言内容后,直接把它渲染到页面上。代码大概长这样:
// 危险写法:直接把用户输入插入innerHTML const comment = document.getElementById('comment-input').value; document.getElementById('comment-list').innerHTML += `<div>${comment}</div>`;如果用户输入的是正常文字,比如“写得不错”,那没问题。但如果用户输入的是:
<img src="x" onerror="alert('你的Cookie是:' + document.cookie)">这段代码会怎样?浏览器解析到<img>标签时,发现src="x"加载失败,于是触发onerror事件,执行里面的JavaScript。结果就是:任何访问这条评论的人,都会弹出自己的Cookie信息。如果攻击者把alert换成一段发送网络请求的代码,那所有访问者的Cookie就会被悄悄送到攻击者的服务器上。
这就是最典型的存储型XSS。恶意脚本被存进了数据库,每次有人访问页面都会执行。还有一种叫反射型XSS,恶意脚本通过URL参数传入,服务器直接把它拼接到返回的HTML里,用户点开链接就中招。第三种是DOM型XSS,整个攻击过程完全发生在浏览器端,服务器甚至不知道发生了什么。
2.2 三种XSS的差异与危害等级
| 类型 | 触发方式 | 是否存储 | 典型场景 | 危害等级 |
|---|---|---|---|---|
| 存储型 | 恶意脚本存入数据库 | 是 | 评论、昵称、文章内容 | 极高 |
| 反射型 | 恶意脚本通过URL参数传入 | 否 | 搜索框、错误提示页 | 中高 |
| DOM型 | 前端JS直接操作不可信数据 | 否 | innerHTML、eval | 中高 |
存储型XSS之所以最危险,是因为它不需要诱导用户点击特定链接,只要用户访问正常页面就会中招。一个浏览量大的网站,如果存在存储型XSS,攻击者可以轻松拿到大量用户的登录凭证。
2.3 为什么“过滤特殊字符”不是万能药
很多人第一反应是:那我直接把<、>、script这些关键词过滤掉不就行了?我试过,这条路基本走不通。原因有几个:
第一,过滤规则永远赶不上绕过手法。比如你过滤了<script>,攻击者可以用<img onerror>;你过滤了onerror,还有onload、onclick、onmouseover等一大堆事件属性。HTML标签和属性太多了,黑名单根本列不完。
第二,不同上下文需要不同的处理方式。用户输入出现在HTML标签之间、出现在属性值里、出现在JavaScript代码里、出现在CSS里,需要的转义规则完全不同。用同一套过滤逻辑处理所有场景,必然有漏网之鱼。
第三,过滤会破坏正常内容。用户想讨论一段代码,输入了<div>,你直接过滤掉,内容就变得不完整。用户体验和安全之间的平衡,需要更精细的方案。
我个人的经验是:永远不要试图“过滤”恶意内容,而是要对所有输出进行“转义”。把用户输入当成纯文本处理,而不是当成HTML的一部分。
3. CSRF:你登录了,但操作不是你做的
3.1 一个让人后背发凉的场景
假设你登录了某银行网站,Cookie里保存着你的身份凭证。然后你打开另一个标签页,访问了一个看起来人畜无害的论坛。论坛里有一张图片,代码是这样的:
<img src="https://bank.example.com/transfer?to=attacker&amount=10000" width="0" height="0">浏览器加载这张图片时,会自动向银行网站发送一个转账请求。因为你的Cookie还在有效期内,银行服务器认为这是你本人发起的操作,于是转账成功。你什么都没做,钱就没了。
这就是CSRF(跨站请求伪造)。攻击者不需要知道你的密码,也不需要窃取你的Cookie,只需要诱导你访问一个页面,就能以你的身份执行操作。
3.2 CSRF和XSS的本质区别
很多人会把XSS和CSRF搞混,其实它们的攻击路径完全不同:
- XSS:攻击者把恶意代码注入到你的页面里,代码在你的浏览器里执行。核心是“代码注入”。
- CSRF:攻击者诱导你的浏览器向目标网站发送请求,请求本身是合法的,但发起者不是你。核心是“身份冒用”。
用一个生活类比:XSS相当于有人偷偷在你家里装了一个窃听器,你说什么他都能听到;CSRF相当于有人拿着你的门禁卡,趁你不注意刷开了你家的门。
3.3 为什么Cookie机制天然容易受CSRF影响
浏览器在发送请求时,会自动带上目标域名下的Cookie。这个设计本身是为了方便——你登录一次,后续请求自动携带凭证。但问题在于,浏览器不会区分这个请求是你主动发起的,还是被其他页面诱导发起的。
只要请求的目标域名和Cookie的域名一致,Cookie就会被带上。攻击者正是利用这一点,在自己的页面上构造一个指向目标网站的请求,借助你的浏览器完成身份认证。
注意:CSRF攻击通常针对的是“状态改变”的操作,比如转账、改密码、发帖、删除数据。单纯的读取操作虽然也可能被利用,但危害相对小一些。
4. 防御XSS:从输出转义到内容安全策略
4.1 输出转义:最基础也最有效的一层
防御XSS的第一原则是:所有不可信数据在输出到HTML页面时,必须根据上下文进行转义。什么叫“不可信数据”?简单说,就是任何来自用户输入、第三方接口、URL参数的内容。
在HTML标签之间输出时,需要转义以下字符:
| 原字符 | 转义后 | 说明 |
|---|---|---|
| & | & | 避免被解析为实体起始 |
| < | < | 避免被解析为标签开始 |
| > | > | 避免被解析为标签结束 |
| " | " | 避免突破属性值边界 |
| ' | ' | 避免突破属性值边界 |
| / | / | 辅助防止标签闭合 |
如果你用的是现代前端框架,比如React、Vue、Angular,它们默认会对插值内容进行转义。比如React里的{comment}会自动转义,但dangerouslySetInnerHTML不会。Vue里的{{ comment }}会自动转义,但v-html不会。框架给了你安全默认值,但一旦你主动绕过它,责任就回到你身上了。
4.2 富文本场景下的白名单策略
有些场景确实需要允许用户输入HTML,比如富文本编辑器、Markdown渲染。这时候不能简单转义,否则所有格式都没了。正确的做法是使用白名单过滤:只允许特定的标签和属性通过,其他一律清除。
我常用的方案是DOMPurify这个库,它专门做HTML净化,支持自定义白名单。比如:
import DOMPurify from 'dompurify'; const dirty = '<img src="x" onerror="alert(1)"><p>正常内容</p>'; const clean = DOMPurify.sanitize(dirty, { ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a'], ALLOWED_ATTR: ['href', 'title'] }); // 结果:<p>正常内容</p>DOMPurify会解析HTML,移除所有不在白名单里的标签和属性。onerror这类事件属性默认就不在白名单里,所以会被直接删掉。
实操心得:白名单策略的关键是“默认拒绝”。不要想着“禁止哪些危险标签”,而是“只允许哪些安全标签”。前者永远列不全,后者边界清晰。
4.3 Content Security Policy:最后一道防线
即使转义做得再好,也难免有遗漏。这时候CSP(内容安全策略)可以作为兜底。CSP通过HTTP响应头告诉浏览器:哪些来源的脚本可以执行,哪些不可以。
一个典型的CSP头:
Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted.cdn.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:;这条策略的意思是:
- 默认只允许加载同源资源
- 脚本只能从同源和指定CDN加载
- 样式允许同源和内联
- 图片允许同源、data URI和HTTPS来源
CSP最强大的地方在于:即使攻击者成功注入了脚本,只要脚本来源不在白名单里,浏览器就不会执行。这相当于给XSS加了一道物理隔离。
不过CSP也有代价。配置太严格可能导致正常功能失效,配置太宽松又起不到防护作用。我建议先从Content-Security-Policy-Report-Only模式开始,只上报违规但不拦截,观察一段时间后再正式启用。
4.4 HttpOnly Cookie:让脚本拿不到凭证
对于会话Cookie,一定要设置HttpOnly属性。这样JavaScript就无法通过document.cookie读取它。即使XSS攻击成功执行了脚本,也拿不到用户的登录凭证。
Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=LaxHttpOnly:禁止JS读取Secure:仅通过HTTPS传输SameSite:限制跨站请求携带Cookie
这三个属性组合起来,能大幅降低XSS和CSRF的影响范围。
5. 防御CSRF:Token、SameSite与二次验证
5.1 CSRF Token:最经典的防御手段
CSRF攻击之所以能成功,是因为浏览器自动携带了Cookie,而服务器无法区分请求来源。CSRF Token的思路是:在页面中嵌入一个随机生成的Token,提交请求时必须带上这个Token,服务器验证Token是否匹配。
攻击者的页面无法读取目标网站的Token(因为同源策略限制),所以无法构造出合法请求。
实现流程:
- 用户访问表单页面时,服务器生成一个随机Token,存入Session,同时嵌入表单隐藏字段。
- 用户提交表单时,Token随请求一起发送。
- 服务器比对请求中的Token和Session中的Token,一致则通过,否则拒绝。
<form action="/transfer" method="POST"> <input type="hidden" name="csrf_token" value="随机生成的Token"> <input type="text" name="to" placeholder="收款方"> <input type="number" name="amount" placeholder="金额"> <button type="submit">转账</button> </form>注意:Token必须足够随机,且每次会话或每次请求都重新生成。如果Token可预测,防御就形同虚设。
5.2 SameSite Cookie:浏览器层面的天然屏障
SameSite是Cookie的一个属性,用来控制跨站请求是否携带Cookie。它有三个值:
| 值 | 行为 | 适用场景 |
|---|---|---|
| Strict | 完全禁止跨站携带 | 安全性要求极高的操作 |
| Lax | 允许顶级导航的GET请求携带 | 大多数网站的默认选择 |
| None | 允许跨站携带,但必须配合Secure | 需要跨站嵌入的场景 |
设置SameSite=Lax后,从其他网站发起的POST请求不会携带Cookie,CSRF攻击自然失效。这是目前浏览器默认推荐的配置,成本低、效果好。
Set-Cookie: sessionId=abc123; SameSite=Lax; Secure; HttpOnly5.3 二次验证:敏感操作的额外保险
对于转账、改密码、删除账号这类高危操作,光靠Token还不够。我建议加上二次验证,比如:
- 要求输入当前密码
- 发送短信验证码
- 使用邮箱确认链接
这样即使CSRF Token被绕过,攻击者也无法完成最终操作。多一层验证,多一层安全。
5.4 防御方案对比与选型建议
| 防御手段 | 防御目标 | 实现成本 | 兼容性 | 推荐程度 |
|---|---|---|---|---|
| 输出转义 | XSS | 低 | 好 | 必做 |
| 白名单过滤 | XSS | 中 | 好 | 富文本场景必做 |
| CSP | XSS | 中高 | 较好 | 强烈推荐 |
| HttpOnly | XSS | 低 | 好 | 必做 |
| CSRF Token | CSRF | 中 | 好 | 必做 |
| SameSite | CSRF | 低 | 较好 | 强烈推荐 |
| 二次验证 | CSRF | 中 | 好 | 高危操作必做 |
实际项目中,我通常会组合使用:输出转义 + HttpOnly + CSP防XSS,CSRF Token + SameSite + 二次验证防CSRF。没有哪一层是绝对可靠的,但多层叠加后,攻击成本会大幅提高。
6. 实战排查:我踩过的坑和修复过程
6.1 一个被忽略的DOM型XSS
之前接手过一个老项目,后端接口返回的数据都做了转义,但前端有一段代码是这样的:
// 从URL hash中读取参数并渲染 const hash = window.location.hash.substring(1); const params = new URLSearchParams(hash); document.getElementById('welcome').innerHTML = '欢迎,' + params.get('name');这段代码的问题在于:params.get('name')来自URL,属于不可信数据,但直接赋给了innerHTML。攻击者可以构造一个链接:
https://example.com/page#name=<img src=x onerror=alert(1)>用户点开后,脚本就会执行。修复方式很简单,把innerHTML换成textContent:
document.getElementById('welcome').textContent = '欢迎,' + params.get('name');textContent会把内容当成纯文本处理,不会解析HTML标签。这个改动只花了一分钟,但堵住了一个真实的漏洞。
6.2 CSRF Token失效的排查过程
有一次测试反馈说,某个表单提交总是报“Token无效”。我排查了半天,发现原因是:页面被缓存了。用户第一次打开页面时拿到了Token A,但页面被浏览器缓存。等Token过期后,用户再次提交,用的还是缓存的Token A,而服务器已经生成了新的Token B。
解决方案有两个:
- 在表单页面设置
Cache-Control: no-store,禁止缓存。 - Token不存Session,而是用无状态的方式生成(比如JWT),并设置合理的过期时间。
最后我们选了方案一,因为改动最小,而且表单页面本来就不应该被缓存。
6.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 页面弹出莫名弹窗 | XSS注入 | 检查所有用户输入输出点 | 输出转义、CSP |
| Cookie被发送到外部 | XSS窃取 | 检查Cookie是否HttpOnly | 设置HttpOnly、Secure |
| 用户未操作却执行了动作 | CSRF | 检查敏感接口是否有Token验证 | 加CSRF Token、SameSite |
| Token验证总是失败 | 缓存或过期 | 检查页面缓存策略和Token有效期 | 禁用缓存、刷新Token |
| CSP配置后功能异常 | 白名单不完整 | 查看浏览器控制台CSP报告 | 调整白名单、先用Report-Only |
实操心得:排查XSS时,重点看所有“用户可控数据”最终去了哪里。是插入HTML了?是拼接到JS里了?还是放进了URL?每个出口都需要对应的转义处理。排查CSRF时,重点看所有“状态改变”的接口,是否只依赖Cookie做身份认证。
7. 把安全变成开发习惯,而不是事后补救
聊了这么多原理和方案,最后我想分享几个让安全落地的实际做法。
第一,把安全检查和代码审查绑定。每次提交代码时,如果涉及用户输入输出,审查者必须确认是否做了转义或过滤。我们团队后来加了一条规则:任何使用innerHTML、v-html、dangerouslySetInnerHTML的地方,都必须有注释说明为什么安全,否则不予合并。
第二,用自动化工具做兜底。比如ESLint插件可以检测不安全的DOM操作,OWASP ZAP可以扫描常见的XSS和CSRF漏洞。工具不能替代人工,但能帮你发现明显的问题。
第三,安全配置要写进项目模板。新建项目时,默认带上CSP头、HttpOnly Cookie、CSRF Token中间件。让安全成为默认选项,而不是每次都要额外记得加。
第四,定期做安全演练。我们每季度会做一次内部的安全自查,模拟XSS和CSRF攻击,看看现有防护是否有效。有一次演练中,我们发现一个内部管理后台的搜索框存在反射型XSS,虽然外部访问不到,但内部人员可能被诱导点击。修复后,整个团队对XSS的敏感度都提高了。
我个人在实际操作中的体会是:Web安全不是一次性的任务,而是一种持续的习惯。你不需要成为安全专家,但你需要知道最常见的攻击方式长什么样,以及如何在日常开发中堵住这些口子。XSS和CSRF只是冰山一角,但把这两个搞明白,你的项目就已经比大多数网站安全了。
最后再分享一个小技巧:如果你不确定某段代码是否安全,可以问自己一个问题——“如果用户输入的是最恶意的内容,这段代码会怎样?”想清楚这个问题的答案,你就知道该怎么改了。