前端安全:XSS / CSRF 与防御

本节目标

XSS(跨站脚本攻击):把恶意脚本注入页面并执行。

存储型:恶意脚本存进服务器(如评论),所有人访问都中招
反射型:恶意脚本在 URL 参数里,诱导用户点击钓鱼链接触发
DOM 型:前端自己把不可信数据写进 DOM(innerHTML / v-html)触发

下面这段 Node 代码真实演示「未过滤的用户输入」如何变成 XSS 注入点:

// 运行环境:Node.js 14+(仅用内置 http)
// 步骤:node brw-l14-server.js  启动在 3000
//       浏览器访问 http://localhost:3000/?name=<img src=x onerror=alert(1)>
//       若服务端直接把 name 拼进 HTML(漏洞版),浏览器会执行 onerror 里的脚本

const http = require('node:http')
http.createServer((req, res) => {
  const url = new URL(req.url, 'http://localhost')
  const name = url.searchParams.get('name') || '访客'

  // ❌ 漏洞版:直接拼接不可信输入(演示用,请勿在生产这么写)
  const unsafe = `<h1>你好, ${name}</h1>`

  // ✅ 安全版:做 HTML 转义(把 < > & " ' 转成实体)
  const escape = (s) => s.replace(/[&<>"']/g, (c) => ({
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
  })[c])
  const safe = `<h1>你好, ${escape(name)}</h1>`

  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
  // 这里输出「安全版」,你可以把 safe 改成 unsafe 亲自看漏洞效果
  res.end(`<p>安全版输出:</p>${safe}<p>把上面 safe 换成 unsafe 再访问带恶意参数的 URL 即可复现 XSS</p>`)
}).listen(3000, () => console.log('http://localhost:3000  (试 ?name=<img src=x onerror=alert(1)>)'))

XSS 防御:① 输出转义(< > & " ');② CSP(Content-Security-Policy: default-src 'self' 限制脚本来源兜底);③ Cookie 设 HttpOnly(XSS 也读不到会话);④ 输入白名单校验,富文本用白名单过滤。

CSRF(跨站请求伪造):用户已登录,恶意网站诱导浏览器自动带 cookie 发请求:

用户登录银行 → 点开钓鱼页 → <img src="/api/transfer?to=hacker&amount=1000">
浏览器自动带 cookie 发出 → 银行以为是本人操作

下面这段 Node 代码真实演示 CSRF 的攻击链路(两个端口模拟「银行」和「钓鱼页」):

// 运行环境:Node.js 14+(仅用内置 http)
// 步骤:node brw-l14-csrf.js  → 先访问 http://localhost:3000/login 登录(种 cookie),
//       再访问 http://localhost:4000 钓鱼页,点里面的链接,看银行是否「真的转了账」
// 注:CSRF 本质是「借你的 cookie 冒名办事」。这里用两个端口(同站不同源)让 demo 能在本地跑通;
//     真实攻击是跨「站点」(attacker.com → bank.com),那时才轮到 SameSite 出手拦截。

const http = require('node:http')

// ① 银行(3000):/login 种一个「已登录」cookie,/transfer 校验 cookie 后转账
http.createServer((req, res) => {
  const loggedIn = (req.headers.cookie || '').includes('login=1')
  if (req.url.startsWith('/transfer')) {
    if (loggedIn) return res.end('⚠️ 银行真的执行了转账!因为请求自动带上了你的登录 cookie(CSRF 成功)')
    return res.end('银行:你未登录,拒绝转账')
  }
  if (req.url === '/login') {
    res.writeHead(200, { 'Set-Cookie': 'login=1' })
    return res.end('你已登录银行(cookie 已种下)。现在访问 http://localhost:4000 试试')
  }
  res.end('银行首页:先访问 /login 登录')
}).listen(3000, () => console.log('银行: http://localhost:3000/login'))

// ② 钓鱼页(4000):诱导用户点一个指向银行的链接(浏览器会自动带上银行 cookie)
http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
  res.end(`<h3>恭喜你中奖了!点我领取</h3>
    <p><a href="http://localhost:3000/transfer?to=hacker&amount=1000">点击领取奖金</a></p>
    <p>你在银行已登录的状态下点这个链接,浏览器会自动带上银行的 cookie,银行会以为是你本人操作。</p>`)
}).listen(4000, () => console.log('钓鱼页: http://localhost:4000(先在 3000 登录,再来点链接)'))

CSRF 防御:① SameSite Cookie(Lax/Strict,跨站不再自动带);② 校验 Origin/Referer;③ CSRF Token(请求带随机 token,服务端校验);④ 敏感操作二次验证。

其他安全头:X-Frame-Options: DENY / frame-ancestors(防点击劫持)、X-Content-Type-Options: nosniff(防 MIME 嗅探)、HSTS(强制 HTTPS)。

名词解释

XSS(跨站脚本,Cross-Site Scripting):攻击者把恶意 JS 注入到别人会访问的页面里执行的攻击。它的「注入方法」常是:把脚本塞进评论/URL 参数/未转义的 DOM 写入。防御方法(对策):输出转义、CSP、HttpOnly cookie、输入白名单。记住:XSS 的本质是「把数据当成了代码执行」。

CSRF(跨站请求伪造,Cross-Site Request Forgery):攻击者借「用户已登录的浏览器」自动携带的 cookie,冒充用户发请求。它的「触发方法」是:诱导用户访问恶意页,页面自动发起跨站请求(<img>/表单自动提交)。防御方法:SameSite Cookie、CSRF Token、校验来源、二次验证。本质是「借你的身份办事」。

CSP(内容安全策略,Content Security Policy):用响应头声明「页面允许从哪些来源加载脚本/样式/图片等」的安全策略。方法:Content-Security-Policy: default-src 'self'; script-src 'self'(只允许同源脚本)。它是 XSS 的「兜底网」——即使有注入,脚本来源不在白名单也会被拦。

课后练习

练习 1:下面 Vue 代码有什么安全隐患?怎么改?

<div v-html="userComment"></div>

答案:若 userComment 来自用户输入且未经白名单过滤,v-html 会把它当 HTML 插入并执行其中的 <script> 或 onerror 等事件,造成 DOM 型 XSS。改法:① 默认不要 v-html,用 {{ }} 文本插值(自动转义);② 若必须渲染富文本(如文章),用 DOMPurify 等库做白名单净化后再 v-html。

练习 2:为什么光靠 HttpOnly cookie 还不足以完全防住 CSRF?

答案:HttpOnly 防的是「XSS 偷读 cookie 内容」,但 CSRF 根本不需要读 cookie——它利用的是「浏览器会自动把 cookie 附带在跨站请求上」这一机制。所以 HttpOnly 对 CSRF 无效。防 CSRF 要靠 SameSite Cookie(跨站不带)、CSRF Token(攻击者页面拿不到)、校验来源等手段。

总结

安全这节我想纠正一个心态:很多人把安全防护当成「上线前补一道」的附属品,但 XSS/CSRF 的可怕之处在于——它们攻击的不是「你的服务器」,而是「你用户的浏览器和身份」。XSS 偷的是用户的数据和会话,CSRF 借的是用户的身份去干坏事,受害者都是用户,锅却算在开发者头上。我的观点是:安全头(CSP、HttpOnly、SameSite)是「低成本高收益」的兜底,必须默认开启;而真正的核心纪律只有一条——「绝不信任任何外部输入,所有进入 DOM 的数据都要么转义、要么白名单净化」。框架的自动转义已经帮你挡了大部分,千万别用 v-html/innerHTML 亲手把门打开。安全不是功能,是底线。