一次 CSP 安全漏洞修复:255 个漏洞怎么清零的
背景
设备 Web 界面要过东南亚安全扫描认证,AppScan 一扫,报了 255 个漏洞,其中 238 个(94%)都跟 CSP 有关。花了 2 周,全部清零,通过认证。这篇文章从"什么是 CSP"讲起,一直讲到怎么把线上样式全丢的诡异问题挖到底。
第一部分:先补点基础
一、XSS 是什么,为什么安全扫描盯着它
先理解"敌人"是谁。XSS(跨站脚本) 说白了就是:攻击者把一段恶意 JS 塞进你的页面,让它在用户的浏览器里执行。最简单的例子:
html
<script>alert('你的网站被我黑了')</script>
就这么一行,只要攻击者能把它注入到你的页面里,用户一打开就会弹窗。把 alert 换成"偷 Cookie"、"伪造请求"、"篡改页面",危害就大了。
XSS 的根源是**"代码"和"数据"混在一起**:用户输入本来只是数据,却被当成代码执行了。安全扫描(AppScan)就是模拟攻击者,挨个页面尝试注入,看能不能成功。
二、CSP 是什么,怎么工作
CSP(Content-Security-Policy,内容安全策略) 就是给浏览器的一份白名单,写在 HTTP 响应头里,告诉浏览器:"这个页面只准加载我点名的那些来源的资源,其他的统统拦掉。"
打个比方:没有 CSP 的浏览器就像没上锁的门 ,谁都能进;有了 CSP,门上装了门禁,只有名单里的人能进。
它怎么工作?很简单:
1. 浏览器收到 HTTP 响应
2. 读响应头里的 Content-Security-Policy
3. 页面里每加载一个资源(脚本、样式、图片、iframe...),浏览器先拿它跟白名单比对
4. 对不上就拦掉,并在控制台报错
关键认知:CSP 是后端加的响应头,前端不改代码它也能生效。 前端做的那些改动,只是为了让自己的代码"能通过这道门禁"。所以修复 CSP 是前后端配合的事,不是纯前端。
三、CSP 白名单有哪几种写法
CSP 每个指令后面跟的值,就是"允许谁"。说白了分两类,记住就行:
✅ 安全的值(该用):
'self'------ 只允许自己服务器的资源'nonce-xxx'------ 只允许带指定"通行证"的脚本(后面细讲,用来替代后门)
❌ 危险的后门(千万别用,用了等于没防):
'unsafe-inline'------ 允许页面里随便写的脚本执行'unsafe-eval'------ 允许eval/new Function把字符串当代码跑
记住一句话:CSP 里只要出现 unsafe-inline 或 unsafe-eval,这道防线就废了。 这次修复的核心,就是把这两个后门堵上、换用 nonce,同时又不让页面功能挂掉。
四、CSP 的几个指令
CSP 不是一句话,而是由一堆指令组成,每个指令管一类资源:script-src 管脚本、style-src 管样式、img-src 管图片、connect-src 管 Ajax/WebSocket、object-src 管插件、frame-ancestors 管"谁能嵌我的页面"等等。每个指令具体取什么值、长什么样,直接看下面改前改后的真实响应头就懂了。
第二部分:改前 vs 改后,一眼看穿问题
五、改前 vs 改后:响应头对比
从浏览器 DevTools 的 Network 里抓到的真实响应头,改之前是这样:
http
Content-Security-Policy: script-src 'self' 'unsafe-inline' 'unsafe-eval'
就这一行。改之后变成了:
http
Content-Security-Policy: object-src 'none'; base-uri 'self'; script-src 'self' 'nonce-xxx' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' http: https: blob: data:; media-src 'self' blob:; connect-src 'self' ws: wss:; default-src 'self'; frame-ancestors 'self'; require-trusted-types-for 'script'
用一张表对比改前改后,每个指令代表什么、还能怎么写:
| 指令 | 改前 | 改后 | 说明 / 还能怎么写 |
|---|---|---|---|
script-src(管脚本) |
'self' 'unsafe-inline' 'unsafe-eval' |
'self' 'nonce-xxx' 'wasm-unsafe-eval' |
改动的核心:nonce 替代 unsafe-inline,wasm-unsafe-eval 替代 unsafe-eval |
style-src(管样式) |
没配(走 default 兜底) | 'self' 'unsafe-inline' |
保留 unsafe-inline 兼容内联样式。 |
object-src(管插件) |
没配 | 'none' |
<embed> 只在 IE 渲染,而 IE 根本不执行 CSP ,所以设 'none' 实际对功能无影响 |
frame-ancestors(谁能嵌我) |
没配(默认谁都能嵌,有点击劫持风险) | 'self' |
只允许同源页面把我嵌进 iframe。还能写成 'none'(但内部有 iframe 嵌套要慎用) |
connect-src(Ajax/WS) |
没配 | 'self' ws: wss: |
同源 HTTP + WebSocket 都放行,RPC 请求和实时推送正常 |
require-trusted-types-for |
没有 | 'script' |
强制 innerHTML 等 DOM sink 走策略审查,兜底 DOM XSS(后面第三招细讲) |
default-src |
没配 | 'self' |
兜底,没单独配的资源一律只准同源 |
base-uri |
没配 | 'self' |
防 <base> 标签被篡改 |
img-src |
没配 | 'self' http: https: blob: data: |
图片各种来源都放行 |
media-src |
没配 | 'self' blob: |
音视频流正常 |
顺带一提:React 的 inline style 特性(CSP 管不到,但要注意)
项目里大量用 React 内联样式,比如 src/base/views/Overview/index.js:484 的图例颜色:
jsx
<span
className='legend-item-icon'
style={{ background: item.color }} // React 内联样式
/>
React 写 style={``{color:'red'}},编译后是给 DOM 元素插入 style="" 属性 (<span style="background:red">)。关键认知:CSP 的 style-src 只管 <style> 标签和 <link> 标签,管不到元素的 style="" 属性 ------浏览器根本没有任何 CSP 指令能控制这个属性,始终允许。所以 React 的 inline style 无论 style-src 怎么配都不受影响,这次修复完全不用动它。
唯一要注意的是:既然 style="" 属性 CSP 管不了,就别把用户输入直接拼进 style={``{}}(比如 style={``{color: userInput}}),否则有样式注入风险。项目里这种写法的值都是程序内常量(item.color 来自固定配置),没有这个问题。
一句话:改前 script-src 开了两个后门,改后用 nonce 关掉 unsafe-inline、用清掉 new Function 关掉 unsafe-eval、再加 Trusted Types 兜底 DOM XSS------三招堵漏,全站还能正常跑。
第三部分:三招修复
六、第一招:nonce ------ 给合法脚本发通行证
原理
nonce 就是一个随机字符串,后端写在 CSP 头里,前端打在每一个 <script> 标签上。浏览器一看:标签上的 nonce 跟响应头里的一致,放行;不一致或没有的,拦掉。
后端 CSP 头: script-src 'nonce-xxx'
页面里: <script nonce="xxx">合法代码</script> ✅ 放行
攻击者注入: <script>alert('XSS')</script> ❌ 没 nonce,拦掉
关键:攻击者没法给注入的脚本加上 nonce,因为他改不了设备返回的响应头,也没法把自己的标签插进服务器返回的 HTML 里。所以注入的脚本一律被拦。
怎么落地:NoncePlugin 的执行流程
当时遇到的问题是------CSP 改完后 script-src 只认带 nonce 的标签,没 nonce 的一律拦。那我就得想办法给页面里所有的 <script>、<style>、<link> 标签都加上 nonce="xxx"。但难就难在,这些标签不是一次性都在的,它们分两种、出现在两个时间点:
- 一种是打包时就定好的标签 :比如
public/index.html里我手写的 Trusted Types 策略注册那段<script>、以及打包工具自动往 HTML 里塞的 js/css 引用标签。这些标签在打包产物index.html里是写死的,我管它叫静态标签。 - 另一种是用户打开页面、浏览器跑起来之后才创建的标签 :项目代码很多是按需加载的(比如点进某个页面才加载那块的代码),打包工具在浏览器里会临时用 JS 创建
<script>标签去加载这些代码块。这些标签打包时根本不存在,是运行时才冒出来的,我管它叫动态标签。
静态标签在打包产物里,打包时我插手改就行;动态标签打包时不在------但它有 src 且同源,CSP 的 'self' 会放行,其实不用我管。真正必须 带 nonce 的是 inline <script>(没有 src 的那些),它们都在静态 HTML 里,被第一条线覆盖了。下面分开讲。
第一条线:静态标签 ------ 我自己写了个 webpack 插件 NoncePlugin
我改动的目的:打包结束后、HTML 写到磁盘之前,把 HTML 里所有标签扫一遍,统一加上 nonce。手动加不现实(标签太多,而且有些是打包工具生成的),所以写个插件让打包工具自动干这事。
插件代码在 config/nonce-plugin.js(整文件 48 行),逻辑很简单,就两件事:constructor 把 nonce 存起来,apply 注册一个"HTML 快写盘时叫我一声"的回调。可以把它理解成一个"等着被叫醒干活的工人"------constructor 是上班时报上自己带的工具(nonce 值),apply 是跟打包工具说"等 HTML 拼好、要写盘那一刻,把 HTML 给我看一眼,我加完东西再还你"。
看代码前先搞懂几个 webpack 插件的写法约定(不是随便写的,是 webpack 规定好的),后面看代码就顺了:
apply(compiler):webpack 规定,任何插件都必须是一个带apply方法的对象/类。webpack 启动时会对 plugins 数组里每个插件调plugin.apply(compiler),把 compiler 实例传进去。apply这个名字是写死的,webpack 内部就是这么调,不能改名;参数 compiler 也是 webpack 自动传进来的。compiler:webpack 的"总管"对象,贯穿整个构建生命周期,所有配置、所有事件钩子都挂在它身上。compiler.hooks:compiler 暴露的事件钩子集合,底层是 webpack 自家的 tapable 库。每个 hook 代表构建过程里的一个时机(比如"编译开始""打包完成")。.tap('名字', 回调):tapable 提供的注册方法------往某个 hook 上注册一个同步回调。'名字'只是这个回调的标识(调试用,随便起),回调在 hook 触发时执行。(还有tapAsync/tapPromise是异步版,这里用同步 tap 就够。)compilationhook:"开始一次编译"时触发,回调参数是 compilation 对象(代表这一次编译过程,HtmlWebpackPlugin 的 hook 就挂在它身上)。
带着这些约定看代码:
js
11:48:config/nonce-plugin.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
class NoncePlugin {
constructor(nonce) { this.nonce = nonce; } // 把 nonce 存到实例上
apply(compiler) { // webpack 启动时调它,传 compiler 进来(apply 名字写死)
// 在 compiler 的 compilation hook 上注册回调:编译开始时执行,拿到 compilation
compiler.hooks.compilation.tap('NoncePlugin', (compilation) => {
// 拿 HtmlWebpackPlugin 暴露的 hooks,再在 beforeEmit(HTML 即将写盘前)注册回调
HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tap('NoncePlugin', (data) => {
// 1. 先清掉所有标签里已有的 nonce(防重复/空值)
data.html = data.html.replace(/\snonce="[^"]*"/gi, '');
// 2. 给 <script> / <style> / <link> 统一注入 nonce
data.html = data.html.replace(/<script/gi, `<script nonce="${this.nonce}"`);
data.html = data.html.replace(/<style/gi, `<style nonce="${this.nonce}"`);
data.html = data.html.replace(/<link/gi, `<link nonce="${this.nonce}"`);
return data;
});
});
}
}
module.exports = NoncePlugin;
代码里两层 tap 是嵌套关系 :外层 compiler.hooks.compilation.tap 等"编译开始" → 拿到 compilation → 内层再 beforeEmit.tap 等"HTML 写盘前"。HtmlWebpackPlugin.getHooks(compilation) 是 HtmlWebpackPlugin 提供的 API,从 compilation 上取出它自己暴露的那组 hooks。注意这一步只是注册回调,还没执行。
这时候这个文件还只是个"类定义",没被执行,nonce 值也还没定下来------要等下一步在配置里 new NoncePlugin('xxx') 才真正上岗。
怎么让它生效 :在 config/webpack.config.prod.js 里把它引入、实例化,塞进打包工具的插件列表:
js
25:25:config/webpack.config.prod.js
const NoncePlugin = require('./nonce-plugin'); // 引入这个"工人"
js
594:598:config/webpack.config.prod.js
new scriptExtHtmlWebpackPlugin({ inline: /runtime.*.js/ }),
// 给所有 <script> 标签添加固定 nonce 属性(CSP 安全策略)
new NoncePlugin('xxx'), // 实例化:把固定 nonce 'xxx' 交给它
new NoncePlugin('xxx') 这一行就是"雇这个工人上岗",把 nonce 值交给他。注意这一步只是上岗,还没干活 ------真正干活是打包跑到 HTML 快写盘那一步,打包工具喊他一声(触发 beforeEmit 回调),他才把 HTML 拿过来加 nonce。
运行起来是这样 :打包工具拿 public/index.html 当模板,把 js/css 引用塞进去拼成完整 HTML → 快写盘时叫醒 NoncePlugin → NoncePlugin 用正则把所有标签加上 nonce="xxx" → 交还给打包工具写盘。最后磁盘上的 index.html 里,每个静态标签都带上了 nonce。
调试坑:F12 里看不到 nonce(浏览器故意隐藏的)
验证 nonce 有没有注入时,会发现一个反直觉的现象:F12 的 Elements 面板里,<script> 标签上根本看不到 nonce 属性 。这不是 NoncePlugin 没生效,而是浏览器故意隐藏的。
原因:CSP 规范为了防止 XSS 攻击者通过 DOM 读取 nonce 值(比如 document.querySelector('script').getAttribute('nonce') 或 script.nonce),要求浏览器在 DOM 里不暴露 nonce 属性------nonce 只对 CSP 检查可见,对 DOM 和 JS 不可见。Chrome 61+、Firefox 75+ 都这么干。
所以验证 nonce 真的注入了,得用查看网页源代码 (Ctrl+U 或右键→查看源代码),不是 F12:
- F12 Elements 面板 → 显示的是浏览器解析后的 DOM → nonce 被隐藏 ❌
Ctrl+U查看源代码 → 显示服务器返回的原始 HTML → nonce 可见 ✅
被问"怎么确认 nonce 生效了",可以答 F12 看不到 nonce 是因为浏览器按 CSP 规范在 DOM 里隐藏了 nonce 属性防 XSS 泄露,得用查看源代码看原始 HTML。
第二条线:动态标签 ------ 实际靠 'self' 放行(同源脚本不需要 nonce)
动态标签是浏览器运行时才创建的------webpack runtime(被 scriptExtHtmlWebpackPlugin 内联在 HTML 里)在用户点到按需加载的功能时,会 document.createElement('script'),设 script.src = '/static/js/xxx.chunk.js',然后 append 到页面头部加载代码块。
那这些动态创建的 <script> 要不要带 nonce?实际上不需要 。关键在于它们都有 src,而且 src 是同源 的(/static/js/... 都是自己服务器上的资源)。CSP 响应头里 script-src 'self' 'nonce-xxx' ... 的 'self' 已经允许同源脚本------有 src 且同源的 <script> 走 'self' 放行,不需要 nonce 。需要 nonce 的只有 inline <script>(没有 src、直接写在 HTML 里的脚本代码),那些已经被 NoncePlugin 覆盖了。
两条线最终对上
实际过 CSP 的两条路径:
- inline
<script>(Trusted Types 注册那段、内联的 runtime 等,没有 src)→ NoncePlugin 打包时注入nonce="xxx"→ 跟响应头nonce-xxx对上 → 放行 - 动态
<script src="/static/js/xxx.chunk.js">(有 src、同源)→ 走'self'放行 → 不需要 nonce
一句话总结:inline
<script>我写了个 webpack 插件,在 HTML 写盘前用正则扫一遍加 nonce,跟响应头对上放行;动态加载的 chunk script 有 src 且同源,走'self'放行,不需要 nonce。
彩蛋:nonce 和 unsafe-inline 同时存在时,unsafe-inline 自动失效
CSP Level 3 有个规则:script-src 里同时 有 nonce 和 'unsafe-inline' 时,浏览器会自动忽略 'unsafe-inline',只认 nonce------这是浏览器的"自动升级安全级别"机制,开发者就算手滑留了 unsafe-inline,只要配了 nonce 就不会被拖累。
项目利用了这个机制做了脚本和样式两条不同策略:
- 脚本
script-src:只留'nonce-xxx',干脆没写 unsafe-inline → inline<script>必须靠 NoncePlugin 注入 nonce 才能活(比如 Trusted Types 策略注册那段、window.less配置那段),否则页面直接挂。 - 样式
style-src:留着'unsafe-inline',没给 style 用 nonce → inline<style>直接靠 unsafe-inline 放行,不用单独处理。
为什么样式敢留 unsafe-inline?因为样式的 XSS 风险远低于脚本(样式最多影响显示,脚本能偷数据、发请求)。这样脚本严管、样式从宽,既堵住高风险口子又少改动。
七、第二招:干掉 new Function() ------ 顺便解开"样式全消失"之谜
这是整个修复里最绕、也最有意思的一块。
症状:页面出来了,但没样式
CSP 一上线,诡异的事发生了:页面内容正常(React 渲染出来了),但所有样式都没了 ------没颜色、没布局、没字体,像裸奔一样。更头疼的是本地完全复现不了,只有线上才这样。
真相:less.js 用 new Function 编译,被 CSP 拦了
问题出在项目处理 less 的机制上。这个机制的核心是一个叫 LessSplitPlugin 的 webpack 插件,先把它讲清楚,后面的"运行时编译 vs 打包编译"才看得懂。
先认识 LessSplitPlugin:sdd-web 体系下处理 less 的插件
LessSplitPlugin 来自 @sdd-web/plugin-less-split 包(源码在 node_modules/@sdd-web/plugin-less-split/index.js),是 sdd-web 前端体系提供的一个 less 处理插件。它专门处理项目根目录下的主题变量文件 theme.less(里面是颜色、字号等变量定义),有一个关键开关参数 parseCSS,决定这个 less 是"打包时编译好"还是"留给浏览器运行时编译":
parseCSS 值 |
行为 | HTML 里生成的标签 | 是否需要 less.min.js |
|---|---|---|---|
true |
打包编译 :构建时用 less.render() 把 theme.less 编译成 theme.css |
<link rel="stylesheet" href="style/theme.css">(普通 CSS) |
不需要 |
false(默认) |
运行时编译:theme.less 原样输出,留给浏览器编译 | <link rel="stylesheet/less" href="style/theme.less"> |
需要(less.min.js 运行时编译) |
需要说明的是:运行时编译这条线设计上 能配合
less.modifyVars()做运行时换主题色------这也是这个插件当初留parseCSS: false分支的本意。但我们项目实际并没有启用换肤功能 ,只是设备本地这条线一直走着运行时编译(parseCSS: false),等于白扛了换肤的复杂性,却没用到换肤的好处。包的 README 也写明了:"parseCSS 为 true 时将在编译时解析 less 为 css,运行时将不再使用 less,换肤失效,主要给上云程序使用。"
原项目为什么要区分"上云"和"生产"
这是理解整个样式消失问题的背景。原配置里 parseCSS 的值是 isCloud(不是写死的 true/false):
js
// config/webpack.config.prod.js(CSP 修复前)
const isCloud = !!(process.env.program_series && process.env.program_series.includes('Cloud'));
// ...
new LessSplitPlugin({
HtmlWebpackPlugin,
minisize: true,
parseCSS: isCloud, // 上云=true / 设备本地=false
prefix: isCloud ? './' : '/'
}),
isCloud 来自环境变量 program_series 是否包含 Cloud,用来区分两种部署形态:
- 上云(Cloud) :
parseCSS: true→ 构建时编译成 CSS → 不依赖 less.min.js。上云环境(云平台托管)要省运行时开销,所以早早就把 less 编译死了,走打包编译这条线。 - 设备本地(生产) :
parseCSS: false→ 留给运行时 less.min.js 编译。设备本地虽然实际没启用换肤,但这条线还留着运行时编译的机制------浏览器加载页面后由 less.min.js 现场把 theme.less 编译成 CSS。
这个区分平时没问题,但 CSP 一上就炸了------因为设备本地这条线(占绝大多数)走的是运行时编译 ,而运行时编译依赖 new Function()。上云那条线本来就没问题(早就编译死了),所以这个 bug 只在设备本地暴露。
要理解它怎么炸的,得先搞清楚"运行时编译"和"打包编译"的区别。
原来用的是"运行时编译"(LessSplitPlugin 把主题变量留给浏览器跑 less.min.js 编译):
webpack 构建时:
LessSplitPlugin 拆分 less → 基础 CSS 编译好 + 主题变量部分原样输出成 theme.less
HTML 里生成 <link rel="stylesheet/less" href="theme.less">
打包产物里带上 less.min.js(运行时编译器)
浏览器加载页面时:
1. 加载 less.min.js
2. less.min.js 扫描 <link rel="stylesheet/less">,发请求下载 theme.less 源码
3. less.min.js 解析 .less 文本 → 用 new Function() 把 AST 转成可执行函数 → 生成 CSS
4. 生成的 CSS 注入成 <style> 标签 → 样式生效
换肤:调 less.modifyVars({primaryColor:'#xxx'}) → 重新编译 → 主题变
问题就在第 3 步:less.min.js 内部编译 less 用的是 new Function() 。CSP 没有 unsafe-eval,new Function() 直接被浏览器的 V8 引擎底层拦掉 → less 编译彻底失败 → 一个 <style> 都没生成 → 全站没样式。
LessSplitPlugin 把主题变量留给运行时编译
→ less.min.js 用 new Function() 编译
→ 被 CSP 拦截
→ 编译失败 → 没有 <style> → 样式全没
为什么本地没事?因为开发环境用的是 hot-less-loader,走的是打包编译------打包时就编译好了,根本不依赖运行时的 less.js。所以本地一直好好的,一上 CSP 才暴露。
修复:改成"打包编译",别让运行时编译了
项目其实不需要换肤功能,所以最干脆------让 webpack 打包时就编译完整 CSS,不要运行时那套。改后的打包编译流程:
webpack 构建时:
LessSplitPlugin parseCSS:true → less-loader + css-loader 直接把 .less 编译成完整 CSS
(主题变量在构建时就被 resolve 成具体值)
MiniCssExtractPlugin 把 CSS 抽成 xxx.css 文件
HTML 里生成 <link rel="stylesheet" href="xxx.css">
浏览器加载页面时:
1. 加载 xxx.css(普通 CSS 文件)
2. 直接应用样式
不需要 less.min.js,不需要 new Function
代码改动就一行------把 LessSplitPlugin 的 parseCSS 参数从 isCloud 改成 true:
js
// config/webpack.config.prod.js
new LessSplitPlugin({
HtmlWebpackPlugin,
minisize: true,
parseCSS: true, // true = 构建时编译完整 CSS;原来 isCloud = 留给运行时 less.min.js
prefix: isCloud ? './' : '/'
}),
parseCSS 参数的含义:true = 构建时编译完整 CSS(变量直接 resolve);false/isCloud = 留给运行时 less.min.js 编译。再把 public/index.html 里的 window.less 配置删掉。不依赖运行时 less.js 了,new Function() 也就不会触发。
顺手清掉代码里其他 new Function
除了 less.js,项目代码本身也有几处用 new Function:
最棘手的一处 :encodeZipCore.js(WASM 加载器核心)。它原本用 new Function 动态拼一个带指定名字的函数------因为 WASM 加载器要求函数有特定的 name。改成普通函数 + Object.defineProperty 设 name,效果一样:
js
// 改前:new Function 拼带名字的函数
return new Function("body",
"return function " + name + "() { 'use strict'; return body.apply(this,arguments); }"
)(body);
// 改后:普通函数 + 设 name 属性
var fn = function() { "use strict"; return body.apply(this, arguments); };
Object.defineProperty(fn, 'name', { value: name, configurable: true });
return fn;
另外三处 :KeepAlive/index.js、FirewallEditModal.js、TableClass/index.js,都是拿 new Function() 当空函数默认值,直接改成 () => {}。
为什么能这么改:new Function() 不带参数时创建的就是一个空函数,调用时啥也不干,等价于 function(){}。这三处的场景都是给回调参数一个默认值 ------比如 KeepAlive 组件有个 onActive 回调,组件激活时调用;使用者没传就用默认空函数占位,调用时啥也不发生,避免 callback is not a function 报错。() => {} 是静态函数定义,调用时同样啥也不干,行为完全等价,但走的是正常函数声明路径、不走 eval,CSP 不拦。所以换过去功能不变、CSP 通过。
还有第三方库 :@sdd-web/h5-player 内部也有 new Function,升到 3.2.16(唐明修过的版本,删掉了 new Function)。IPC 那边是自己写的无插件库,也一并处理了。
代码里这些 new Function 全都干掉了。
八、第三招:Trusted Types ------ 给 innerHTML 装个安检
为什么要这招
前两招堵住了"外部脚本注入"和"代码注入执行"。但还有个口子:应用自己代码里的 innerHTML = 某个字符串。如果这个字符串里混进了用户输入,照样是 DOM XSS。
CSP 加一条 require-trusted-types-for 'script',强制所有 innerHTML / outerHTML / document.write 赋值必须过"策略审查",直接传字符串会被浏览器拦。
怎么落地:注册个"原样放行"的默认策略(其实是被逼的)
说实话,这招落地是被逼出来的,不是一开始就想要"原样放行"。
一开始开了 require-trusted-types-for 'script',结果页面到处报错 This document requires 'TrustedHTML' assignment------因为 React、第三方库(Swiper 等)大量用 innerHTML / dangerouslySetInnerHTML。试过逐个改:把每个 innerHTML 包成 TrustedHTML 对象,工作量大且容易漏,改了这个那个又报错。也试过用 DOMPurify 消毒:但有些场景消毒后内容变了(富文本被清掉标签),样式/功能不对。怎么改都丢样式、报错,最后没办法了。
转念一想:项目里所有 dangerouslySetInnerHTML 的内容都是设备返回的已知文本(法律条款、搜索关键词),不是用户自由输入的,XSS 风险本来就低。所以干脆注册个默认策略,三个方法都原样返回入参 ,相当于"过安检扫一下就放行"------这样所有 innerHTML 自动走默认策略 → 原样放行 → 不报错不丢样式,13 个用到 innerHTML 的文件一行都不用改,第三方库也照常工作。
代码放在 public/index.html 的 <body> 最开头(必须最早执行,否则后面的 innerHTML 会被拦):
html
<script>
if (window.trustedTypes && window.trustedTypes.createPolicy) {
try {
window.trustedTypes.createPolicy('default', {
createHTML: function(s) { return s; },
createScriptURL: function(s) { return s; },
createScript: function(s) { return s; }
});
} catch(e) {}
}
</script>
机制:浏览器拦截 innerHTML → 自动丢给 default 策略的 createHTML → 策略原样返回 → 放行。这是在"安全性"和"改造成本"之间的权衡------已知内容低风险,放行合理。
createScriptURL不能漏------React 打包后会生成<script src="...chunk.js">,没这个方法页面直接报错。
try/catch是兜底:如果 CSP 的trusted-types指令禁用名为default的策略,createPolicy会抛 TypeError,包 try/catch 防止注册失败导致后续 JS 报错。
一个重要认知:Trusted Types 不能替代 unsafe-eval
"既然 Trusted Types 有 createScript,能不能用它绕过 new Function 的拦截?"答案是不行。这是两层独立的检查:
调用 new Function(string)
↓
【第 1 层】CSP script-src 检查(V8 引擎底层,C++ 级)
→ 没有 unsafe-eval → 直接拦死,到此结束
↓(只有 unsafe-eval 才过得去)
【第 2 层】Trusted Types 检查(浏览器渲染层)
→ createScript 放行
第 1 层就拦死了,根本轮不到第 2 层。而且 createScript: function(s){return s} 的风险和 unsafe-eval 完全等价------任何字符串都能变成 TrustedScript 被执行。所以 new Function() 必须从代码里清掉,没有捷径。这正是第二招必须做的原因。
Worker 要单独再注册一次(Worker 和 Trusted Types 的关系)
先说什么是 Worker:浏览器里 JS 默认单线程,跑在主线程。如果有个耗时任务(比如视频解码)直接在主线程跑,会卡住整个页面。Worker 是浏览器提供的"后台线程",可以在不阻塞主线程的情况下跑 JS。项目里 h5-player 用 5 个 Worker 做音视频解码:keepAlive(保活)、audioWorker(音频解码)、videoWorker(视频解码)、audioTalkWorker(对讲音频)、videoWorkerTrain。
为什么 Worker 和 Trusted Types 有关系?关键在于作用域隔离:
- Worker 跑在独立的全局作用域,跟主线程是两个隔离的环境
- 主线程注册的变量、函数、策略,Worker 里都访问不到(除了通过
postMessage传数据) - Trusted Types 策略是绑定在"当前全局作用域"上的------主线程注册的 default 策略,只对主线程生效,Worker 里没有
- Worker 里如果调了
importScripts('xxx.js')(Worker 专用的加载脚本 API),相当于给 Worker 加载脚本,属于 TrustedScriptURL sink - Worker 没注册策略 → 浏览器拦 → 报错
This document requires 'TrustedScriptURL' assignment
解决办法:在 Worker 文件最开头 注入一段注册 default 策略的代码,让 Worker 启动时最先注册好策略,后续 importScripts 才能过。但 Worker 文件是 h5-player 第三方库提供的,不能直接改源码(升级会覆盖)。所以用 webpack 的 CopyWebpackPlugin 的 transform 钩子,在拷贝 .worker.js 时动态往头部拼接策略代码:
执行流程:
1. webpack 构建 → CopyWebpackPlugin 拷贝 .worker.js 到打包目录
2. transform 钩子检测到 .worker.js → 在文件头部拼接 default 策略注册代码
3. 浏览器加载 Worker → Worker 启动
4. Worker 最先执行头部的策略注册代码 → default 策略注册好
5. 后续 importScripts('xxx.js') → 走 default 策略 createScriptURL → 放行
代码:
js
// config/webpack.config.prod.js CopyWebpackPlugin
transform(content, path) {
if (path.endsWith('.worker.js')) {
var prefix = 'if(typeof trustedTypes!=="undefined"&&trustedTypes.createPolicy){' +
'try{trustedTypes.createPolicy("default",{createScriptURL:function(s){return s},' +
'createScript:function(s){return s},createHTML:function(s){return s}})}catch(e){}}\n';
return Buffer.concat([Buffer.from(prefix), content]);
}
return content;
}
第四部分:nonce 为什么用固定值(重点讲好处)
项目里 nonce 在 webpack 配置里写死成 'xxx',前后端都用这一个值,没有每次请求随机生成。很多人第一反应是"这不安全吧?"------这个直觉是对的 。CSP 的标准做法确实是每次请求由服务端用 crypto.randomBytes(16).toString('base64') 动态生成 nonce,固定值会少一层防御(后面细说)。我们用固定值不是因为它"更安全",而是在这套架构和威胁模型下做的权衡------用一点安全性换来了对嵌入式设备很实在的工程收益。下面先说代价,再说好处。
好处一(其实是代价):安全性确实打了一点折扣,但在当前威胁模型下可接受
先讲清楚固定 nonce 到底少了什么防御。
nonce 防的是:攻击者把恶意 <script> 注入页面执行。要绕过,攻击者得给注入的 <script> 加上跟响应头一致的 nonce。
随机 nonce(标准做法)多的一层防御 :每次请求 nonce 不同,攻击者无法预知本次请求的 nonce 是多少 。这样即使攻击者找到了一个 HTML 注入点(比如某处参数反射到页面、或某个 XSS),他也没法构造出带正确 nonce 的 <script>------因为他不知道这次请求的 nonce 值。
固定 nonce 少的就是这层 :nonce 值恒定不变,攻击者打开一次页面源码就知道了。一旦项目里出现 一个 HTML 注入点,攻击者就能用这个已知的固定 nonce 构造 <script nonce="xxx">恶意代码</script> 直接注入执行------nonce 这道防线就形同虚设了。
所以说"安全性没打折扣"是不诚实的,固定值确实少了一层"防 HTML 注入"的纵深防御。
那为什么我们还敢用固定值? 因为在本项目的威胁模型里,这层防御缺失的风险可以接受:
- HTML 是写死的静态文件,没有后端动态拼接 HTML 的环节(不存在 SSR、不存在参数反射到 HTML 的模板渲染)------目前没有 HTML 注入入口
- 这是设备 Web 配置界面,用户都是已认证的管理员,不是面向公众的开放网站,攻击面小
- 项目代码里没有把用户输入直接拼进 HTML 的写法(React 默认转义,
dangerouslySetInnerHTML的内容都是设备已知文本)
换句话说:只要项目不出现 HTML 注入点,固定 nonce 和随机 nonce 防护等效;固定值的风险在于"万一未来出现注入点,nonce 防线会失效"。对这个威胁模型(静态托管、认证用户、无注入入口),这个风险是可接受的权衡。
"固定 nonce 不安全吧",可以这么答:标准做法确实是每次请求随机生成,固定值会少一层防 HTML 注入的纵深防御------一旦出现注入点,已知 nonce 会被攻击者复用。我们用固定值是权衡:本项目是设备 Web 配置界面,HTML 是纯静态文件、用户都是认证管理员、没有参数反射到页面的场景,HTML 注入风险低。在这个威胁模型下用固定值,换来 HTML 可强缓存、零运行时开销、部署简单。这是安全性和工程成本的权衡,不是说固定值没降低安全性。
追问:那为什么还要硬加这个 nonce?
这个问题很容易被追问到------既然固定 nonce 有代价,那干脆不加 nonce 行不行?
不行,nonce 不是"锦上添花",而是"去掉 unsafe-inline 后的必需品"。链路是这样的:
- 扫描要求
script-src不能留'unsafe-inline'------它是个后门,留了等于 CSP 形同虚设,AppScan 直接报漏洞 - 去掉
unsafe-inline后,页面里所有 inline<script>(没有 src、直接写在 HTML 里的脚本)必须有 nonce 才能执行------没 nonce 的 inline script 一律被浏览器拦 - 项目 HTML 里就是有 inline script:Trusted Types 策略注册那段、
window.less配置那段......这些没 nonce 就全被拦,页面直接挂
所以 nonce 是堵 unsafe-inline 这个后门的替代方案 ------不是"要不要加"的选择题,是"只要去掉 unsafe-inline,inline script 就必须带 nonce 才能活"。要么留 unsafe-inline(扫描不过),要么加 nonce(让 inline script 能跑),没有第三条路。
关键对比 :固定 nonce 的"打折扣"是跟随机 nonce 比;但要跟留 unsafe-inline 比的话,固定 nonce 是质的提升------
- 留
unsafe-inline:门大开,任何 inline script(包括攻击者注入的)随便跑 - 固定 nonce:门上装了门禁,在"无 HTML 注入点"的前提下,攻击者注入的 inline script 没带正确 nonce 就被拦
所以整个 CSP 修复的本质是:把"门大开(unsafe-inline)"换成"装门禁(nonce)",固定 nonce 只是这个门禁的通行证是固定的------比门大开强太多了,只是不如随机通行证那么完美。
好处二:HTML 可以走强缓存,省设备性能
嵌入式设备的 CPU 和 Flash 都很弱。如果 nonce 每次请求随机:
- HTML 每次响应内容都不同 → 浏览器没法缓存 → 每次都要重新下载、重新解析整个 HTML
- 设备 web server 每次都要生成随机数、动态拼装 HTML → 增加 CPU 和内存开销
而固定 nonce 的 HTML 是恒定不变的静态文件,浏览器可以强缓存(304 命中),设备端零动态开销。对一个性能吃紧的嵌入式设备来说,这个收益很实在。
好处三:零运行时开销,架构简单
固定 nonce 在打包时就注入好了,运行时什么都不用做。如果要用随机 nonce,得把整个 HTML 从"静态托管"改成"后端动态模板渲染"------每次请求都拼一遍 HTML、注入随机值,架构复杂度陡增。固定值让前端构建产物、后端静态托管天然对齐,前后端不用额外协调。
好处四:部署简单,一次构建多处用
同一份打包产物可以烧到多台设备、多产品线复用,nonce 天然一致,不用为每台设备单独生成。维护成本最低。
好处五:满足合规扫描
AppScan / STQC 这类扫描工具的判定逻辑是:CSP 头里有 nonce、且页面标签上的 nonce 与之一致、没有 unsafe-inline/unsafe-eval,就算通过。固定值完全满足这个要求。安全扫描看的是"机制对不对",不是"值随不随机"。
那什么时候才必须用随机 nonce?
随机 nonce 的额外价值在于:项目存在 HTML 注入风险时------比如动态 HTML(SSR)、有用户输入拼进 HTML 的模板渲染、参数反射到页面。一旦出现注入点,固定 nonce 会被攻击者复用绕过,而随机 nonce 让攻击者无法预知本次请求的值,构造不出带正确 nonce 的标签。我们这套静态托管、无注入入口的架构不属于这种情况,才可以用固定值做权衡;换成有 HTML 注入风险的项目,就必须用随机 nonce。
第五部分:收尾
九、哪些功能受影响、哪些不受影响
改完配置得验证一遍功能,分清楚哪些会被 CSP 误伤:
| 功能 | 走什么通道 | 受影响? |
|---|---|---|
| 系统升级 / 读卡器升级 | RPC2 上传 + RPC 轮询 + WebSocket 通知 | 不受影响 |
| 配置导出 | 文件下载型 CGI(window.open / <a download>) |
不受影响 |
| OCX 插件 | Chrome 走 H5 不渲染 <embed>,IE 才用 |
不受影响(IE 不执行 CSP) |
| 法律页面 / 搜索高亮 | dangerouslySetInnerHTML,内容是设备已知文本 |
不受影响(默认策略放行) |
| U 盘导入导出的事件推送 | iframe 加载 /SubscribeNotify.cgi,CGI 往 iframe 推内联 <script> |
受影响 |
受影响的那块:CGI 推流机制详解
最后那个"U 盘导入导出事件推送"受影响,得详细说说 CGI 推流是怎么回事。
设备端有种"服务器主动推送"机制。普通 HTTP 是一问一答(请求→响应),但设备有些事件是持续发生的(升级进度、报警、状态变化),需要主动推给前端。WebSocket 没普及前,老办法是用 iframe + 流式 HTML 模拟推送:
1. 前端创建一个隐藏 iframe,src 指向 /SubscribeNotify.cgi?event=xxx
2. 浏览器向设备发起这个请求
3. 设备不关闭连接(HTTP 长连接 / chunked transfer),保持连接开着
4. 设备有事件时,就往这个连接里"吐"一段 HTML:
<script>var json={event:'upgradeProgress',progress:50}; parent.receiveMessage(json);</script>
5. 浏览器收到一段就解析一段,遇到 <script> 立即执行
6. 脚本里调 parent.receiveMessage(json):
- parent 指 iframe 的父窗口(我们的页面)
- receiveMessage 是页面预先定义好的回调函数
- 收到 json 就更新 UI(比如进度条从 50% 跳到 60%)
7. 设备继续吐下一段 <script>...,循环
这就是"建立连接 → 一个一个接收 script → 执行回调"的过程,本质是用 iframe + 流式 HTML 模拟服务器推送。
为什么受 CSP 影响:设备吐出来的 <script> 是 inline script,没有 nonce。但关键认知是------iframe 里的文档归 CGI 响应头自己的 CSP 管,不归主页面的 CSP 管 。所以要看 /SubscribeNotify.cgi 响应头怎么配:
- 没加 CSP 头 → 不拦(脚本能跑),但 AppScan 报漏洞
- 加了 CSP 头但 script 没 nonce → 被拦 → 事件推送失效 → 进度条卡住
修复得 CGI 负责人配合:给 /SubscribeNotify.cgi 响应加 CSP 头,每个推送的 <script> 加 nonce。注意 nonce 必须由 CGI 自己生成,不能用主页面的固定 nonce 值(主页面和 CGI 是两个独立文档,CSP 上下文不共享)。
十、复现的一个坑
最后说个调试上的坑:本地 HttpServer 复现不了线上环境 ,因为代理 HttpServer 不给静态资源加 CSP 响应头。办法是直接改 HttpServer 的 staticHandler.js 自己加头。最好自己改,要不调试几次还得找应用那边配合,麻烦死了。
十一、总结
一句话概括:把"形同虚设"的 CSP 升级成"真正生效"的 CSP ,代价是前端得消化掉 unsafe-inline 和 unsafe-eval 带来的方便。
| 后门 | 替代方案 | 怎么做 |
|---|---|---|
unsafe-inline |
nonce 通行证 | NoncePlugin 打包时给 inline 标签注入 nonce;动态 chunk 同源走 'self' 放行 |
unsafe-eval |
清掉 new Function() |
禁掉运行时 less.js 编译 + encodeZipCore 用普通函数替代 + h5-player 升级 + 默认参数改 () => {} |
| DOM XSS | Trusted Types | 默认策略原样放行,13 个 innerHTML 文件零改动 + Worker 单独注册 |
最值得讲的不是写 nonce 插件------那个不难------而是样式全消失的根因定位 :线上才有、本地复现不了,最后自己改 HttpServer 加响应头才稳定复现,顺着 LessSplitPlugin → less.min.js → new Function → V8 拦截 这条链挖到底。
最终 255 个漏洞清零,通过认证。整个过程对 CSP 各指令的边界(入站还是出站、管标签还是管属性、两层独立拦截)、dev 和 prod 构建差异、嵌入式设备静态托管下固定 nonce 反而更优的原因,都有了实打实的理解。