CSP 安全漏洞修复

一次 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 就够。)
  • compilation hook:"开始一次编译"时触发,回调参数是 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 后的必需品"。链路是这样的:

  1. 扫描要求 script-src 不能留 'unsafe-inline'------它是个后门,留了等于 CSP 形同虚设,AppScan 直接报漏洞
  2. 去掉 unsafe-inline 后,页面里所有 inline <script>(没有 src、直接写在 HTML 里的脚本)必须有 nonce 才能执行------没 nonce 的 inline script 一律被浏览器拦
  3. 项目 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 反而更优的原因,都有了实打实的理解。

相关推荐
陌离2 小时前
Go-Gui:一套 无限接近 现代化UI的 GPU绘制界面组件框架
前端
咚咚咚小柒2 小时前
【前端】LangChain.js学习
前端·javascript·人工智能·typescript·langchain·vue·ai编程
2401_885885043 小时前
巴林语音通知接口如何对接?系统告警语音API接入教程
android·前端·网络·python·语音识别
java1234_小锋3 小时前
【技术专题】CSS3 网页技术 - CSS3 盒模型
前端·css·css3
计算机魔术师3 小时前
手握芯片、又要吞下开源分发入口,英伟达被盯上了
前端
JSON_L3 小时前
TCP 粘包拆包解析及示例
网络·网络协议·tcp/ip
段一凡-华北理工大学3 小时前
高炉能源系统智能化:热风炉、煤气与极致能效~系列文章07:高炉煤气:产生规律与全厂用户平衡
java·前端·能源
汤愈韬4 小时前
OSPF外部路由引入
网络·网络协议
前端架构师-老李4 小时前
HTML与CSS基础第四课—strong、em、b、i 各表示什么
前端·css·html