一、为什么前端安全问题总是「被忽略」
很多团队都在聊性能优化、工程化、体验设计,但真正把 前端安全 写进项目规范、CodeReview Checklist 的其实不多。
直到有一天:
-
运营说:用户被自动弹出的广告"劫持"到奇怪网站;
-
测试说:账号凭空被人改了密码;
-
安全部门说:日志里发现大量异常请求,追溯下去发现是前端页面被人玩坏了......
这些问题,80% 都能归到三个老朋友身上:
-
XSS(跨站脚本攻击)
-
CSRF(跨站请求伪造)
-
点击劫持(Clickjacking)
本文会从这三个攻击方式入手,结合真实场景 & 代码示例,聊清楚:
-
它们到底是怎么发生的?
-
为什么「只改一行前端代码」就能把风险降一大截?
-
在实际业务中,前端工程师能做什么?
二、基本认知:浏览器安全模型 & 同源策略快速回顾
在进入具体攻击方式前,有两个基础概念一定要清楚:
1. 浏览器信任模型
浏览器默认认为:
-
用户主动打开的网站 是「相对可信」的;
-
来自这个网站的 JS 有权:
-
读写当前页面 DOM;
-
发起网络请求;
-
访问所在域下的 Cookie(如果没加
HttpOnly);
-
攻击者要做的,就是想办法 让自己的恶意脚本「伪装」成来自你网站的代码。
2. 同源策略(Same-Origin Policy)
简单来说:协议 + 域名 + 端口 完全相同 → 同源。
同源策略限制了:
-
不同源页面之间的 DOM 访问;
-
不同源接口之间的响应读取;
但它 并不阻止跨站请求本身 。
这就是 CSRF 能成立的原因之一:请求可以发出去,只是 JS 不能读响应而已。
三、XSS:从一条评论开始的灾难
3.1 XSS 是什么?
XSS(Cross-Site Scripting,跨站脚本攻击) 本质是:
用户可控的输入,未经安全处理就被当作 HTML/JS 输出到页面中,从而变成了攻击者的代码。
常见三种形态:
-
反射型 XSS:恶意脚本存在 URL 里,通过某个接口「原样返回」。
-
存储型 XSS:恶意脚本被存储在 DB / 缓存里,比如评论、昵称、签名。
-
DOM 型 XSS :前端 JS 自己把用户输入拼成 HTML 插入 DOM(
innerHTML等)。
3.2 真实案例(简化版):评论系统里的一行 script
先看一个简化的评论模块(伪代码):
后端接口返回示例
[
{
"id": 1,
"author": "张三",
"content": "这篇文章写得真不错!"
},
{
"id": 2,
"author": "攻击者",
"content": "<script>alert('XSS from comment!')</script>"
}
]
前端渲染代码(错误示例)
<ul id="comment-list"></ul>
<script>
fetch('/api/comments')
.then(res => res.json())
.then(list => {
const ul = document.getElementById('comment-list');
ul.innerHTML = list.map(item => `
<li>
<strong>${item.author}</strong>:${item.content}
</li>
`).join('');
});
</script>
这里的关键点:
-
item.content直接拼接进了 HTML; -
浏览器在解析
innerHTML字符串时,会把<script>...</script>当成正常脚本执行;
于是用户打开这个页面时,攻击者的脚本也被执行了。
实际项目里,攻击者不会只弹个
alert,更常见的是:
读取
document.cookie,上传到攻击者服务器;注入恶意广告 / 伪造登录框;
利用你页面里的登陆状态发起接口调用(配合 CSRF 做更复杂操作)。
3.3 修复方式:最根本的是「输出编码」
思路: 用户输入可以很"脏",关键在于输出到 HTML 时 不要让浏览器把它当成 HTML。
1)手写简单的 HTML 转义函数
function escapeHtml(str = '') {
return str
.replace(/&/g, '&') // 注意顺序:先转 &
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
然后改造渲染逻辑:
ul.innerHTML = list.map(item => `
<li>
<strong>${escapeHtml(item.author)}</strong>:
${escapeHtml(item.content)}
</li>
`).join('');
这样,<script> 就会被当成 普通文本 显示,而不是执行。
2)尽量使用 DOM API,而不是 innerHTML 拼字符串
更彻底一点,直接用 DOM API:
ul.innerHTML = '';
list.forEach(item => {
const li = document.createElement('li');
const strong = document.createElement('strong');
strong.innerText = item.author; // innerText/ textContent 都会自动做 HTML 转义
li.appendChild(strong);
const textNode = document.createTextNode(':' + item.content);
li.appendChild(textNode);
ul.appendChild(li);
});
经验总结:
-
UI 组件层,尽量不用
innerHTML; -
必须用时(比如渲染 Markdown),也要先走可信的解析库(如对 HTML 做白名单过滤);
3.4 前端框架下的「伪安全感」
以 React / Vue 为例:
-
正常插值是安全的:
// React 中
{comment.content}// React 会自动对内容做转义{{ comment.content }} -
危险点在这里:
-
React:
dangerouslySetInnerHTML -
Vue:
v-html
-
一旦你用这些指令/属性,框架就不会帮你转义,你要自己承担所有安全后果。
<!-- 高危 -->
<div v-html="comment.content"></div>
如果确实需要 v-html(比如展示部分可信富文本),尽量做到:
-
只对 内部配置 / 后台运营可控的内容 使用;
-
或者前置一层 HTML 过滤(白名单标签、过滤
<script>、onerror等属性)。
3.5 再往前一层:CSP(Content Security Policy)
即使有 XSS 漏洞,CSP 也能 大幅减轻危害面。
例如,在响应头里加上:
Content-Security-Policy: default-src 'self'; script-src 'self'
含义是:
-
只允许从当前域名加载资源;
-
只允许页面使用本站脚本,禁止内联脚本和第三方脚本;
更严格一点的写法(简化示例):
Content-Security-Policy: default-src 'self';
script-src 'self' 'nonce-r4nd0m';
object-src 'none';
frame-ancestors 'none';
然后在 HTML 中这样写脚本:
<script nonce="r4nd0m">
// 只有带匹配 nonce 的内联脚本可以执行
</script>
重点:
-
CSP 不能代替 正确的输出编码;
-
但它是非常重要的一道「安全兜底」。
四、CSRF:浏览器帮你「自动带上 Cookie」有多危险
4.1 CSRF 是什么?
CSRF(Cross-Site Request Forgery,跨站请求伪造) 利用了两个事实:
-
浏览器访问某个域名时,会自动带上该域名下的 Cookie;
-
服务器通常只通过 Cookie 判断「你是谁」,而不关心请求是从哪个页面发起的。
于是攻击者可以:
在自己控制的页面中,构造一个「对你网站的请求」,利用用户当前登录状态,替用户执行敏感操作。
4.2 一个典型案例:改密码 / 转账
假设有这么个改密码接口:
POST /api/changePassword
Cookie: session=xxxx
oldPassword=123456&newPassword=654321
如果服务端只依赖 Cookie 鉴权,没有额外校验,那么攻击者可以在恶意页面里这么写:
<form id="hacker" action="https://example.com/api/changePassword" method="POST">
<input type="hidden" name="oldPassword" value="123456">
<input type="hidden" name="newPassword" value="hacked123">
</form>
<script>
document.getElementById('hacker').submit();
</script>
当用户在登录状态下访问了这个页面:
-
浏览器自动带上
session=xxxx; -
表单被偷偷提交;
-
服务端看到这是「合法的带登录态的请求」,于是执行改密码操作;
4.3 CSRF 和 XSS 的关系
-
XSS:攻击者把脚本代码「放进你的网站里」;
-
CSRF:攻击者在自己的页面里「假装是你的网站在发请求」;
真实场景中,两者有时会 联动:
-
有 XSS 漏洞 → 攻击者可以在你的站点 JS 环境中直接发敏感请求;
-
没 XSS 但 CSRF 保护不足 → 只要用户点进攻击者页面就可能中招;
4.4 防御策略一:SameSite Cookie
现代浏览器支持 SameSite 属性来限制第三方 Cookie 使用:
Set-Cookie: session=xxxx; Path=/; HttpOnly; Secure; SameSite=Lax
SameSite 的常见取值:
| 取值 | 行为简述 |
|---|---|
| Strict | 任何「跨站请求」都不带这个 Cookie |
| Lax | 大多数跨站请求不带,顶层 GET 导航 会带(如点击链接) |
| None | 跨站请求也会带,必须加 Secure(仅 HTTPS) |
一般建议:
-
强安全场景 → Strict 或 Lax
-
需要嵌入第三方站点 iframe / 跨站接口复杂 → 再考虑
None; Secure,但要辅以其他手段
4.5 防御策略二:CSRF Token(最常见也最有效)
基本思路:
-
后端生成一个随机 Token,同时:
- 通过 Cookie / 模板渲染传给前端;
-
前端在每次发请求时,把 Token 放在:
-
请求体字段或 -
自定义请求头(如X-CSRF-Token)里;
-
-
服务端校验:
- Cookie 里的 session + 请求中的 Token 是否匹配;
简单示例(前端 axios 拦截器):
import axios from 'axios';
const instance = axios.create({
baseURL: '/api',
withCredentials: true, // 让浏览器自动带上 Cookie
});
// 简单示例:从 meta 标签中读取 CSRF Token
function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
instance.interceptors.request.use((config) => {
const token = getCsrfToken();
if (token) {
config.headers['X-CSRF-Token'] = token; // 约定一个自定义 header
}
return config;
});
export default instance;
后端(伪代码):
app.post('/api/changePassword', (req, res) => {
const csrfTokenFromHeader = req.headers['x-csrf-token'];
const csrfTokenFromSession = req.session.csrfToken;
if (!csrfTokenFromHeader || csrfTokenFromHeader !== csrfTokenFromSession) {
return res.status(403).json({ message: 'CSRF validation failed' });
}
// 通过验证,执行后续逻辑
});
前端职责:
-
把「获取 CSRF Token 并加到请求上」封装到统一的请求层(比如 axios 实例);
-
不要在业务代码里一个个手动加,容易漏;
4.6 防御策略三:限制敏感操作方式 & 校验来源
- 敏感操作尽量用 POST / PUT / DELETE,而不是 GET
攻击者用 <img src="..."> 构造 GET 请求非常容易,用 POST 就麻烦多了(需要 form 或 JS)。
- 对 JSON 请求 + CORS 预检结合 CSRF 防御
通常建议:
-
敏感接口使用
Content-Type: application/json; -
通过 CORS 限制允许的域名;
-
同时配合 CSRF Token;
- Referer / Origin 校验(辅助手段,不是主防线)
服务器可以检查:
-
Origin或Referer是否是可信域名; -
但这两个头在某些场景下可能被省略 / 修改,所以不能完全依赖。
五、点击劫持(Clickjacking):你的按钮不再是你的按钮
5.1 原理概述
点击劫持最经典的一句话解释:
把你的网站嵌在一个透明的 iframe 里,遮在攻击者页面的按钮上,用户以为点的是 A,其实点到了你页面里的 B。
简化 Demo:
<!-- 攻击者页面 -->
<html>
<head>
<style>
iframe {
position: absolute;
top: 0;
left: 0;
opacity: 0.01; /* 几乎不可见 */
width: 800px;
height: 600px;
z-index: 999;
}
button.fake {
position: relative;
z-index: 1;
}
</style>
</head>
<body>
<button class="fake">点我领取 100 元优惠券</button>
<iframe src="https://example.com/account/delete"></iframe>
</body>
</html>
当用户点击"领取优惠券"按钮时,实际上是点击到了 iframe 里的「删除账号」按钮。
5.2 防御策略一:X-Frame-Options
HTTP 响应头:
X-Frame-Options: DENY
含义:此页面 禁止被任何页面通过 <iframe>、<frame>、<object> 等方式嵌入。
常见取值:
-
DENY:任何情况都不允许嵌入; -
SAMEORIGIN:只能被同源页面嵌入; -
ALLOW-FROM uri:现在基本已经不推荐,兼容性也不好。
在大多数系统后台 / 管理端页面上,直接设为 DENY 是比较稳妥的。
5.3 防御策略二:CSP 的 frame-ancestors
在 CSP 中可以精细控制「谁能框住我」:
Content-Security-Policy: frame-ancestors 'self' https://trusted.example.com;
含义:
-
允许同源页面嵌入;
-
允许
trusted.example.com这个站点嵌入; -
其他都不行。
frame-ancestors 是 X-Frame-Options 的「升级版」,未来建议优先使用它(两者也可以同时设置以兼容老浏览器)。
5.4 防御策略三:前端自查(Frame Busting,辅助)
前端 JS 可以做一个简单的检查:
if (window.top !== window.self) {
// 说明当前页面被嵌入到了 iframe 中
window.top.location = window.location.href;
}
不过:
-
这只是「补充防线」,因为有些浏览器 / 运行环境会限制
top的访问; -
新项目不建议仅依靠这种方式,还是要配合响应头;
六、把安全「工程化」:前端可以做的几件实事
很多安全问题,不是「某个开发不懂安全」导致的,而是:
-
没有统一约束;
-
没有统一封装;
-
没有统一检查。
6.1 在 UI 组件层建立「安全基线」
例如,在内部组件库里:
-
所有展示纯文本的组件,一律使用
textContent/ 插值语法; -
所有可能展示富文本的场景,统一用一个
SafeHtml组件包一下;
示例(React):
type SafeHtmlProps = {
html: string;
};
function sanitize(html: string): string {
// 这里可以调用 DOMPurify 等库做白名单过滤
// 这里先简单返回,真实项目千万不要直接 return!
return html;
}
export function SafeHtml(props: SafeHtmlProps) {
return (
<div
className="safe-html"
dangerouslySetInnerHTML={{ __html: sanitize(props.html) }}
/>
);
}
之后全项目禁止直接使用 dangerouslySetInnerHTML,必须走 SafeHtml。
6.2 封装统一的请求层:自动带上安全信息
-
封装 axios / fetch 实例;
-
在拦截器里:
-
自动加 CSRF Token;
-
自动处理错误码;
-
日志中打印关键信息,便于审计;
-
示例(继续沿用之前的 axios):
import axios from 'axios';
const http = axios.create({
baseURL: '/api',
withCredentials: true,
});
http.interceptors.request.use((config) => {
const token = getCsrfToken();
if (token) {
config.headers['X-CSRF-Token'] = token;
}
return config;
});
http.interceptors.response.use(
(resp) => resp,
(error) => {
// 统一处理 401 / 403 等权限问题
if (error.response && [401, 403].includes(error.response.status)) {
// TODO: 触发统一的登出 / 提示逻辑
}
return Promise.reject(error);
},
);
export default http;
业务代码只用 http.get/post,不用关心安全细节的实现,难以"忘记"。
6.3 静态检查 & CodeReview 约束
可以在 ESLint / 自定义脚本里针对某些「危险用法」给出警告:
-
使用
innerHTML/outerHTML→ 全部警告; -
使用
eval、new Function→ 禁止; -
直接在组件里使用
dangerouslySetInnerHTML→ 禁止;
简单示例(ESLint 规则片段概念化):
// .eslintrc.cjs
module.exports = {
rules: {
'no-eval': 'error',
'security/detect-non-literal-fs-filename': 'warn',
// 可以引入 eslint-plugin-security 或自行编写规则
},
};
同时在 CodeReview Checklist 里明确一条:
- 是否存在未转义/未过滤的用户输入直接输出到 DOM / HTML / JS 的场景?
七、一个真实修复过程:从 Bug 报告到安全基线升级(简化版)
下面这个案例来源于真实项目,我做了模糊化处理,但过程是真实的。
7.1 事件起因:日志里出现了奇怪的 URL
安全监控系统报警:短时间内出现大量请求,Referer 指向一个不认识的域名。
排查后发现:
-
有用户在某篇文章评论里贴了一个「带脚本的标签」;
-
这条评论被渲染到了前端页面;
-
所有打开该文章的用户,都会不知不觉访问攻击者域名;
7.2 分析过程
-
重现场景:
-
打开文章详情页;
-
发现某条评论内容是:
<img src="xxx" onerror="...">; -
页面加载时,错误触发
onerror,执行了恶意脚本;
-
-
定位代码:
-
评论展示组件使用了
v-html="comment.content"; -
评论内容完全来自后端返回,后台没有任何过滤;
-
前端也没有做转义 / 过滤;
-
7.3 修复方案(短期 + 中期 + 长期)
短期止血:
-
后端立刻对现有评论做清洗:
- 删除 / 替换所有
<script>、onerror、onload等危险标签与属性;
- 删除 / 替换所有
-
前端紧急发布修改,临时对
comment.content做 HTML 转义,改为以纯文本展示;
中期处理:
-
设计一套简化的富文本白名单:
-
允许
<b>、<i>、<a>、<code>、<pre>...; -
禁止所有事件属性(
onxxx); -
禁止
<script>、<iframe>、<object>等;
-
-
后端统一做 HTML 过滤;
-
前端仅在后台可控内容上使用
v-html,用户评论仍然走纯文本路径;
长期建设:
-
在 CDN / 网关层增加 CSP 配置,限制脚本加载来源;
-
把「避免直接使用 innerHTML/v-html」写进前端开发规范;
-
在组件库中封装
SafeHtml,禁止随意使用v-html; -
给所有新页面加
X-Frame-Options/frame-ancestors头,防止点击劫持;
7.4 教训总结
-
大多数 XSS/CSRF 问题并不是「高深黑魔法」,而是非常「直白的疏忽」;
-
一次事故,往往意味着 规范、工具、基础设施 的缺位;
-
安全是「全链路」的:前端、后端、运维、安全同学都要协同。
八、最后的安全 Checklist(可直接贴到团队 Wiki)
如果你只想带走一个表,可以是这个------
XSS 相关
-
所有用户可控内容输出到页面时是否做了 正确的输出编码?
-
项目中是否避免直接使用
innerHTML/outerHTML? -
是否对
dangerouslySetInnerHTML/v-html做了封装和限制? -
是否对富文本内容(运营后台配置)做了 白名单 HTML 过滤?
-
关键页面是否配置了合理的 CSP(
script-src、object-src)?
CSRF 相关
-
所有敏感写操作(改密码、转账、修改个人信息等)是否采用 POST/PUT/DELETE,而不是 GET?
-
是否在这些操作上启用了 CSRF Token 机制?
-
Cookie 是否设置了合理的
SameSite属性?(Lax / Strict) -
是否有统一的 前端请求封装,自动携带 CSRF Token?
-
是否对关键接口做了
Origin/Referer的辅助校验?
点击劫持相关
-
后台 / 管理类页面是否设置了
X-Frame-Options: DENY或 CSPframe-ancestors 'none'? -
必须通过 iframe 嵌入的页面,是否明确指定了可信的
frame-ancestors域名? -
是否在必要页面增加了
window.top !== window.self的前端检测(作为辅助)?
写在最后
前端安全,听起来像是「安全团队」的职责,但真正能关掉那些最典型漏洞的,往往是我们这些写页面、写组件、写接口调用的前端。
-
一点点编码习惯(少用
innerHTML/v-html); -
一点点工程封装(统一请求、统一输出);
-
一点点约束(ESLint 规则、CR Checklist);
就可以让绝大多数「低成本攻击」无功而返。
