前端安全:XSS / CSRF 与防御
本节目标
- 分清 XSS 的三种类型,并用一段 Node 代码演示「未转义用户输入」如何被注入
- 理解 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) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
})[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 要靠SameSiteCookie(跨站不带)、CSRF Token(攻击者页面拿不到)、校验来源等手段。
总结
安全这节我想纠正一个心态:很多人把安全防护当成「上线前补一道」的附属品,但 XSS/CSRF 的可怕之处在于——它们攻击的不是「你的服务器」,而是「你用户的浏览器和身份」。XSS 偷的是用户的数据和会话,CSRF 借的是用户的身份去干坏事,受害者都是用户,锅却算在开发者头上。我的观点是:安全头(CSP、HttpOnly、SameSite)是「低成本高收益」的兜底,必须默认开启;而真正的核心纪律只有一条——「绝不信任任何外部输入,所有进入 DOM 的数据都要么转义、要么白名单净化」。框架的自动转义已经帮你挡了大部分,千万别用 v-html/innerHTML 亲手把门打开。安全不是功能,是底线。