React Native 0.84 鸿蒙 JSBridge 白名单网关:内嵌 H5 越权调用原生的三层拦截与真机实测
React Native 0.84 鸿蒙 JSBridge 白名单网关:内嵌 H5 越权调用原生的三层拦截与真机实测
示例仓库:https://atomgit.com/dijun_520/WebViewSecurityLab
社区引导:框架源码与版本发布见 https://atomgit.com/CPF-RN/ohos_react_native/tree/0.84-main
CPF-RN 鸿蒙社区:https://atomgit.com/CPF-rn
1. 一条越权告警,暴露的是整个桥
事情是从一条安全扫描告警开始的。那天上午我们的 App 刚发完版,安全平台的报告先到了:应用内 WebView 页面存在"原生能力过度暴露"风险,级别是高危。我当时的第一反应是"扫描器误报了吧,我们桥又不是没写"——把报告展开一看,它复现用的代码只有四行:
for (const key in window) {
if (/bridge|native/i.test(key)) {
console.log(window[key].getDeviceInfo()); // 序列号、IMEI、MAC,全明文
}
}
【改造前】:可以枚举到桥,也可以拿到明文设备信息。
为了让你不用想象,我们把这段代码原样跑在了真机的 ArkWeb 里(实测台探针页内置了同款枚举攻击,只把 console.log 换成页面渲染)。改造前的配置下,它枚举命中 NativeBridge,typeof getDeviceInfo === 'function',一次调用拿回五个明文字段;页面下方四条探针同步"越权成功",右上角挂着"危险配置"的红色标签:

【改造后】:枚举命中桥没错,但拿不到明文设备信息了。
切到改造后配置,同一段攻击代码的战果变成了这样:桥对象还在(枚举依然命中,这符合预期——桥名不是秘密),getDeviceInfo 也还在白名单里,但返回值在出口处被统一脱敏,sn、imei、mac、phone 全部变成 **** 掩码,六条探针全部"已阻断":

这两张截图的对比正好说明了本文的立场:枚举 window 找桥这件事防不住,也不需要防——攻击者永远能发现你的桥;安全的目标是让"发现"换不来"能力"。
我把这段代码贴到真机的 WebView 调试控制台里跑了一遍,控制台立刻吐出一串设备信息。那一刻的感受很难形容——不是因为攻击多精妙,而是因为它太朴素了:朴素到我之前一直觉得"这不算漏洞,桥本来就是要给 H5 用的"。
原因分析:
我们的 App 是 RN 0.84 鸿蒙容器,里面大量页面是运营侧维护的 H5:活动页、订单详情、客服会话、用户协议。这些页面跑在 ArkWeb(鸿蒙的 WebView 容器)里,通过一个挂在 window 上的桥接对象调用原生能力——拿设备信息、拉收银台、读离线包配置。这个对象,各家命名不一样,我们叫 NativeBridge。
复盘时我们把线上版本的桥实现翻出来,问题比告警描述的还要多一层。三个"没想到":
没想到一:桥是全量导出的。 注册桥对象时把十几个方法一次性塞进导出列表,参数不校验、来源不问、返回值不脱敏。也就是说,任何一个被加载到容器里的页面——包括活动页引的十几个三方 SDK——都天然拥有全部原生能力。
没想到二:校验是让 H5 自己做的。 当时在 H5 侧有一个"权限判断"工具函数。校验逻辑放在被攻击的一方手里,等于让保险柜自己判断来者是不是小偷。同理,"我们只在自家域名加载 H5"也不成立:WebView 的页面会发生跳转、重定向、被插入 iframe——后来实测我们就真拦截到一次白名单域名发出的重定向跳转(m.yourdomain.com → 一个仿冒域名)。信任必须在每一次调用上重建,而不是在页面加载时确认一次。
没想到三:返回值是明文直出的。 getDeviceInfo 把序列号、IMEI、MAC、androidId、手机号原样塞进 JSON 交给 H5。只要 H5 侧有任何日志、埋点、崩溃上报,这些字段就跟着扩散出去。
这类问题在 OWASP 的移动安全测试指南里是单列的测试项(WebView 内的 JavaScript 与原生交互),各家公司的安全红线也基本都有对应条目。我们内部红线编号 JS-01 到 JS-06,翻译过来是六句话:
- JS-01:桥不提供通用调用入口,方法必须逐个注册;
- JS-02:每次调用校验页面来源,来源从容器读,不信 H5 自报;
- JS-03:参数按 schema 校验,未声明的参数直接拒绝;
- JS-04:返回值脱敏,脱敏在原生侧做,不指望 H5;
- JS-05:按方法维度限流,敏感能力用户二次确认;
- JS-06:确认必须一次一授权,不能确认一次终身有效。
光有红线还不够,得证明"风险是真的、修复是有效的、代价是可接受的"。所以我们把三条验证一起做了:拿真机跑探针、做前后对比实测、把修复写成可单测的代码。真机探针的结果先说一句:六条越权探针,改造前的配置下四条成功——桥接读文件直接拿到了应用沙盒里的文本内容,桥接执行命令返回了进程身份,设备信息五个字段全部明文回传。这不是理论风险,是拿着真机十分钟就能复现的事实。完整的对比数据在第 2 章。
还有一个更隐蔽的动机值得单独说:发版节奏。H5 可以热更,原生不能。如果白名单的"准入判断"写在 H5 里,热更一次就能绕过;写在原生侧,热更绕不过去。所以红线 JS-01 的本质是把准入权收回原生。
也许有人会想"等框架或容器提供一套安全桥再接入"。我们评估过这条路:容器层能给你的是导出机制(methodList)和可选的 URL 级 permission 配置,但"哪些方法该开、参数怎么限、返回什么字段要打码"永远只有业务自己知道——这些知识不在框架里,也不会有。攻防两侧的成本也完全不对称:攻击者枚举你的桥只需要一个控制台,而你修复一次越权要走完整的原生发版。所以答案是现在就收口,而且从最便宜的那层开始收。
2. 效果先看:改造前后,同一台真机
按老规矩,先给效果再讲原理。为了不让"前后对比"被质疑是两个安装包的行为差异,我们做了一个专门的实测台(仓库里 bench-hap/,129 KB 的纯 ArkTS HAP):两种配置做进同一个 HAP,用开关切换,页面、探针、设备、网络、安装方式全部不变。采集链路是:页面内 6 条 JS 探针执行完把结果回传原生,原生用 hilog 打出 [BENCH_RESULT] 标记,主机侧 hdc shell hilog -x | grep BENCH_RESULT 收敛。
改造前配置下的实测画面(探针执行中,"越权成功"即攻击得手):

同一套探针、同一台设备,切到改造后配置的结果(六条全阻断):

2.1 六项指标对比(含判定口径与测量工具)
| # | 指标 | 判定口径 | 改造前 | 改造后 | 变化 | 测量工具 |
|---|---|---|---|---|---|---|
| 1 | 高危配置命中 | 7 个容器配置逐项核对,处于危险档记 1 项 | 7 / 7 | 0 / 7 | −7 | 应用内配置审计器(src/security/webviewConfig.ts)+源码对照 |
| 2 | JSBridge 越权成功 | 6 条探针中任一条拿到本不该给 H5 的数据或能力,记 1 次 | 4 / 6 | 0 / 6 | −4 | 页面内 6 条 JS 探针 + javaScriptProxy,结果 JSON 回传原生 |
| 3 | 敏感字段明文外泄 | getDeviceInfo 返回的 5 个字段中未打码的个数 | 5 / 5 | 0 / 5 | −5 | 探针返回值逐字段正则审计(是否含 *) |
| 4 | 外链导航拦截 | 5 次真实 location.href 导航中被取消的 BLOCK 次数 | 0 / 5 | 4 / 5 | +4 | onLoadIntercept 回调 + hilog(grep BENCH_NAV) |
| 5 | 首屏加载耗时 | onPageBegin→onPageEnd,5 轮取中位数 | 25 ms | 7 ms | −18 ms | ArkTS Date.now() 在容器回调里打点 |
| 6 | 进程 PSS | hidumper --mem <pid> 采样 | 125.9 MB | 127.1 MB | +1.2 MB(+0.9%) | hdc shell hidumper --mem <pid> |
前四项是安全面,后两项是代价面。只报安全收益、不报内存代价,等于把改造说成免费的——这种结论在评审会上活不过两轮追问。
2.2 差异是怎么来的:逐项机理
数据本身没有信息量,差异的成因才有。
指标 1(7/7 → 0/7):不是"改了几个值",而是"能力边界被重新划了一遍"。
改造前的配置沿用了联调期的"能开就开"版本:文件访问开着,H5 就能通过 file:// 与相对路径探到应用沙盒里的文件;DOM 存储开着,H5 就能把 token、会话标识持久化到 Web 层,这些数据会跟着 WebView 的 profile 一直留着;混合内容允许,https 页面里的 http 资源就成了注入入口;调试开关开着,等于给整台设备开了一条 DevTools 协议的远程注入通道。这七项里没有一项是"性能优化",全是能力开关;每多开一项,桥的校验就多一条绕过路径——校验再严,H5 直接读文件、直接开调试口,网关根本不在链路上。
技术手段:把这些开关收敛成一份不可变基线(WEBVIEW_BASELINE),取值用字面量类型锁定,再在应用启动时跑一遍自检,把"危险档"直接判成构建/启动失败。配置是声明式的,靠人记不住,必须靠机器拦。
指标 2(4/6 → 0/6):差异的根因不是"拒绝得更狠",而是"方法根本不在"。
这是本文最核心的一条。改造前 methodList 把 6 个方法全量导出,H5 侧 typeof NativeBridge.readSecretFile === 'function' 成立,一次调用就执行到原生,中间没有任何一层问"这个页面是谁"“这个参数合不合法”。改造后 methodList 只导出 3 个,readSecretFile、execCommand 在 H5 侧不存在——探针拿到的是"方法未导出",不是"调用被拒绝"。
这个区别决定了攻防成本:被拒绝的方法,攻击者还能试参数变形、试大小写、试原型链;不存在的方法,连试的对象都没有。所以我们的顺序是先做减法(不导出),再做校验(导出后的每一次调用),而不是反过来在导出层外面套一个 if。
技术手段:三层收口——容器导出层给最小 methodList;网关层用注册表把"方法名 → 参数 schema + 来源范围 + 频控配额 + 是否需要确认令牌"绑死;方法内部再做业务级权限(比如"只能查自己的订单")。三层缺一层都不成立:只有导出层没有网关,参数还是随便传;只有网关没有导出层,攻击面还在。
指标 3(5/5 → 0/5):明文不是"没脱敏",是"没有统一出口"。
改造前每个桥方法自己拼返回值,getDeviceInfo 把序列号、IMEI、MAC、androidId、手机号原样塞进 JSON 交给 H5。问题在于:只要有一个方法忘了脱敏,整条防线就漏了,而"忘记"是必然发生的——新增方法的人根本不知道有这条规矩。
改造后在网关出口统一收口:所有返回值先过 sanitize(),按字段策略递归处理(保留前缀、掩码中段、超长截断),未在字段白名单里声明的一律丢弃。H5 侧拿到的是 SN-DEMO-****,页面展示不受影响,但明文不再进入 Web 层的日志、埋点和崩溃栈。
顺带说一句为什么不指望 H5 脱敏:脱敏的前提是"原文已经到过 H5",那它就已经进了 JS 内存、进了三方 SDK 的上报队列,脱敏只是给截图打码。
指标 4(0/5 → 4/5):拦截点必须放在"导航发起时",不能放在"页面加载后"。
改造前根本没有 onLoadIntercept,location.href = '...' 直接被内核执行,页面跳走、Cookie 跟着走、Referer 跟着走。改造后每次导航回调都拿 URL,解析 origin 与白名单做精确域名匹配,不匹配就 return true 取消。
为什么是 4 而不是 5:5 次导航里 2 条是白名单直连(本就该放行),另外 3 条非白名单目标(其中 2 条是钓鱼与内网仿冒域名)全部拦下;第 4 次是计划外的——白名单页面 m.yourdomain.com/help 加载后发生了一次到 m.bedpage.com 的重定向,被同一个拦截器在重定向时再次拦下。我盯着这条计划外的日志愣了几秒:它恰好证明"页面加载时校验一次来源"的方案有洞——页面内容可以被重定向污染,之后发起的桥调用如果只信首次来源就漏了。同一份白名单,既要用在导航上,也要用在每一次桥调用的来源校验上。
技术手段:onLoadIntercept 里做精确匹配(不用 endsWith,否则 evil-yourdomain.com 能过);只放行 https;被拦 URL 全部落审计日志(BENCH_NAV),方便事后定位是哪个页面在往外跳。
指标 5(25 ms → 7 ms):这笔账不能记在网关头上。
差异来自容器侧:关掉 DOM 存储与文件访问后,WebView 初始化时少了存储分区挂载和文件域映射。它跟桥接网关没有因果关系——网关只在你调桥的时候才执行,首屏根本没调桥。
技术手段:如果真想知道网关的开销,正确做法是同一套配置下开关网关再测一轮(我们这次没做,所以不宣称网关"零开销",只说它在单次调用的亚毫秒级:白名单是一次数组查找,参数校验是有限字段循环,脱敏有深度上限)。把配置差异说成安全改造的性能红利,是最容易被拆穿的那种漂亮话。
指标 6(+1.2 MB):这 1.2 MB 花在哪,要能说清。
增量来自三块常驻结构:方法注册表(Map,十几条记录)、频控滑动窗口(每个方法一个时间戳数组)、调用审计环形缓冲(固定 N 条)。剩下的是每次调用产生的临时对象,会被 GC 收走。
技术手段:环形缓冲写死容量(我们给 200 条),审计只存"方法名 + 结果码 + 时间 + 来源域"而不存原始参数(既省内存又避免二次泄露),注册表按 scene 懒加载。1.2 MB 在 200 MB 级进程里是 0.9%,属于噪声量级,但优化方向必须写清楚——不然下次有人加功能把缓冲改成无上限,内存就会慢慢涨上去。
2.3 这组数据的边界
- 单设备单系统:只有 TLR-AL00 / OpenHarmony 7.0.0.105 / API 26,没有覆盖低端机、折叠屏和其他 API 版本;不同 ROM 的 Web 内核版本差异可能改变耗时绝对值。
- 样本量小:每配置 5 轮,只报中位数不报方差。毫秒级计时受首帧渲染、系统调度影响,不足以谈置信区间。
- PSS 是单点采样:改造前阶段(启动后 14 s)与改造后阶段(50 s)各一次,不是稳态峰值,不能当内存泄漏结论。
- 网络是现场 Wi-Fi:外链探针依赖真实公网,DNS 与跳转行为不可完全复现,但拦截判定(BLOCK/ALLOW)与网络质量无关。
- "改造后 0/6"的含义:指这 6 条探针全部失效,不代表桥绝对安全;第 6 章列的坑里,至少有三条是我们明确知道还没堵住的。
下面这张是测量收尾时的完整画面。除了六条探针的逐条判定,注意页面底部的落盘日志行——“结果已写入 /data/storage/el2/base/haps/entry/files/bench-result.json”,同一条结果同时经 hilog 以 [BENCH_RESULT] 标记输出,主机侧用 hdc shell hilog -x | grep BENCH_RESULT 即可无 root 采集;日志行里还带了设备标识(TLR-AL00 · OpenHarmony 7.0.0.105),保证数据可追溯到具体设备与系统版本:

复测验证(2026-11-05):文章初稿发布后,我们在同一台设备上重跑了一轮完整的"改造前 → 改造后"测量,结论可复现——改造前配置下越权探针得手、敏感字段明文回传(下图左),切到安全基线后六条探针全部阻断、指标归零(下图右)。两张截图均为真机直接截取(1084×2412,HUAWEI TLR-AL00 · OpenHarmony 7.0.0.105):
| 复测 · 改造前(越权得手) | 复测 · 改造后(全阻断) |
|---|---|
![]() | |
![]() | |
RN 侧的演示工程是一个把九大场景拆开的"WebView 安全实验室"。首页本身就是一块红线配置自检面板:容器风险评分、命中问题数、严重/高危计数一目了然,33 条红线里 26 条是阻断项,未通过则禁止出生产包——这套自检逻辑对应仓库 src/security/webviewConfig.ts:

点进"场景 02"就是本文的主角。Bridge 控制台可以选样本、发起调用、看每一次调用的完整审计链:

一次正常调用 device.getUserProfile 的审计链与返回值。注意"返回给 H5 的数据"一栏:realName 只剩姓、手机号 138****8000、身份证 110101********1234、银行卡 6222 **** **** 5678——脱敏发生在返回给 H5 之前:

桌面端跑同一套网关逻辑的输出(npm run demo:bridge,13 条用例全过,这也是仓库 CI 里的回归用例):
[PASS] 2. 正常调用:用户资料(scene 只影响埋点)
返回 {"nickname":"林舟","realName":"林*","phone":"138****8000",
"idCard":"110101********1234","bankCard":"6222 **** **** 5678",...}
[PASS] 7. 路径穿越:../../app-secret.txt
结果 PATH_ESCAPE · 路径被拒绝:路径穿越出沙盒
[PASS] 8. 二次编码穿越:%252e%252e%252fapp-secret.txt
结果 PATH_ESCAPE · 路径被拒绝:路径穿越出沙盒
[PASS] 12. 支付:重放上一次的确认令牌
结果 CONFIRM_INVALID · 确认令牌已被使用(重放)
[PASS] 13. 频控:1 分钟内第 6 次支付调用
结果 RATE_LIMITED · 已按方法维度限流
下面进入实现。
3. 原理:桥的三层边界,和平台给的两个"限制"
两天改造做完,回头看鸿蒙给桥这件事准备的东西,最大的感受是:难的判断系统都替你做了,交给开发者的只是一层薄薄的导出机制——"能做什么"的准入,框架管不了,必须业务自己建。具体拆开是三层,一层比一层贴近我们的代码:
| 层 | 承担的判断 | 能表达的事 | 表达不了的事 |
|---|---|---|---|
容器导出层(javaScriptProxy.methodList) | 这个方法对 H5 存在吗 | 方法名列表,静态确定 | 参数规则、频次、脱敏、来源 |
| 策略网关层(本文核心) | 这个调用合法吗 | 来源、参数 schema、频控、二次确认、沙盒路径 | ——(纯 TS,逻辑自由) |
| 数据出口层 | 返回值里能出现什么 | 递归脱敏、字段白名单、审计 | —— |
只做容器层导出白名单够不够?不够。methodList 只能表达"方法名列表",表达不了参数规则、频次、脱敏。只做网关层够不够?也不够——网关能拒绝调用,但方法名本身已经暴露给了 window 上的对象枚举,攻击面描述和最小暴露原则是两回事。三层各管一段,任何一层被绕过(比如未来某个容器版本行为变化),后面还有兜底。
平台还给了两个官方写明的"限制",一开始觉得碍事,做完才发现全是优点:
限制一:javaScriptProxy 的所有参数不支持更新。 组件创建之后你再改 methodList 数组,容器不会理会(官方文档明确写了,见文末参考资料 [1])。我们最初的设计是"不同页面挂不同方法子集,页面切换时改数组",实测永远是初始那一份在生效,最后改成:白名单按容器实例定义,要换白名单就重建 Web 组件。后来反而觉得这个"限制"是优点——它从容器层面杜绝了"H5 诱导运行时扩充导出列表"的可能,白名单天然只能冷启动确定。
限制二:调试开关一开,桥就多了一条旁路。 setWebDebuggingAccess(true) 的时候,攻击者(或者一个被诱导的测试同事)可以从调试控制台直接调桥,网关拦得住未注册方法,拦不住"合法方法 + 非法意图"。所以生产包强制关闭调试是红线清单里和桥并列的一条,桥的安全结论以调试关闭为前提。
另外 API 12+ 还有一个 permission 参数,支持给桥配 object / method 两级的 URL 白名单(参考资料 [1])。它的定位和本文的网关不重复:容器侧 permission 管"哪个 URL 能碰哪个方法",网关管"这个方法能传什么参数、回什么数据"。两者可以叠加,我们的实测台没启用 permission(探针页是 resource://rawfile 内部页),生产接入时建议作为第四道保险先在容器侧把 URL 收窄。
4. 工程怎么搭:一个纯 TS 网关,一个实测台
Flutter 同学们把原生逻辑集中在一个 ArkTS 文件里的做法我们很认同,我们的拆法也是"所有和桥打交道的东西集中在一处":
rn-harmony-bridge-gateway/
├── examples/bridge-gateway/ # 本文主角:纯 TS 网关,不接真机也能跑
│ ├─ src/gateway.ts # 网关本体:注册表/校验/频控/令牌/脱敏
│ ├─ src/native.ts # 模拟原生侧注册的方法
│ └─ src/demo.ts # 13 条用例,CI 回归入口
├── src/security/ # RN 工程内的安全模块(与网关同一套策略)
│ ├─ webviewConfig.ts # 容器配置基线 + 自检
│ ├─ bridge.ts # 容器导出层与网关的接线
│ └─ urlGuard.ts # 导航白名单(与桥共用一份 origin 判定)
├── bench-hap/ # 真机实测台:纯 ArkTS HAP + 6 条探针
├── results/bench-result.json # 真机原始数据(逐轮/逐条)
└─ docs/ # 红线基线、审计速查、实测报告
两个设计决定值得先说:
决定一:网关写成纯 TypeScript,不依赖 RN 也不依赖 ArkTS。 它只依赖一个 NativeBridgeAdapter 接口,能力层(真机上是 ArkTS / 原生模块)只做"取数据、执行动作",不做任何鉴权。职责分开之后,能力层可以随便换,策略层不动,单测也只需要 mock 一个 adapter。最直接的收益是桌面端和真机跑的是同一段逻辑:13 条用例在 Node 上全绿,就意味着真机上的策略行为是同一份——桥的策略从来不需要"到真机上才能验"。
决定二:白名单与导出列表同源。 methodList 不是手写的,是从网关注册表推导出来的 EXPORTED_METHODS 常量,同时用这份常量生成给 H5 团队的类型声明。手写导出列表必然漂移:注册表删了一个方法,导出列表忘了删,攻击面就多一个。同源之后,"删方法"是一处改动,导出与文档自动跟着走。
5. 核心实现:把每段代码讲透
5.1 注册表:整个桥的唯一出口
网关的全部代码在 examples/bridge-gateway/src/gateway.ts。先看类型与注册表(文件第 1 段):
```typescript
/**
* JSBridge 网关(策略层)
*
* 设计原则:H5 侧永远拿不到“任意原生能力”,只能命中注册表里的方法;
* 每个方法的参数规格、频次上限、脱敏字段全部在原生/JS 主线程侧声明。
* 本文件是纯 TypeScript,无 RN / ArkTS 依赖,可直接被单测与真机容器共用。
*/
/** 能力分组,用于审计与按组授权 */
export type Scope = 'device' | 'file' | 'pay' | 'location' | 'ui';
/** 单个参数的规格:类型 + 必填 + 长度 + 字符集 + 枚举 + 数值区间 */
export interface ParamSpec {
type: 'string' | 'number' | 'boolean';
required: boolean;
maxLength?: number;
pattern?: RegExp;
enum?: readonly string[];
min?: number;
max?: number;
}
/** 一个方法在注册表中的完整描述 */
export interface MethodSpec {
name: string;
scope: Scope;
desc: string;
params: Record<string, ParamSpec>;
/** 返回体里必须打码的字段,递归匹配同名字段 */
redact: readonly string[];
/** 每分钟调用上限,按方法维度独立计数 */
ratePerMin: number;
/** 是否需要用户在原生弹窗里显式确认 */
needsConfirm?: boolean;
}
/** 调用上下文:由容器在每次调用时构造,H5 无法伪造 */
export interface BridgeContext {
/** 当前页面的 host,来自 WebView 的 URL 而不是 H5 自报 */
origin: string;
now: number;
/** 该页面最近一分钟内的调用时间戳(按方法分开统计) */
history: Record<string, number[]>;
/** 用户确认后原生下发的令牌,一次性 */
confirmToken?: string;
/** 令牌消费记录,防止重放 */
consumedTokens: Set<string>;
}
export type BridgeCode =
| 'OK'
| 'WHITELIST_DENIED'
| 'ORIGIN_DENIED'
| 'SCHEMA_INVALID'
| 'RATE_LIMITED'
| 'NEED_CONFIRM'
| 'CONFIRM_INVALID'
| 'PATH_ESCAPE';
export interface AuditStep {
step: string;
pass: boolean;
detail: string;
}
export interface BridgeResult {
ok: boolean;
code: BridgeCode;
message: string;
data?: unknown;
audit: AuditStep[];
/** 命中红线的编号,便于和发布门禁清单对齐 */
redline?: string;
}
/** 注册表:唯一的能力出口。名字必须带域前缀,禁止 invoke/call/exec 这类通用入口 */
export const METHODS: readonly MethodSpec[] = [
{
name: 'device.getDeviceInfo',
scope: 'device',
desc: '设备型号与系统版本,标识类字段全部打码',
params: {},
redact: ['serial', 'imei', 'mac', 'androidId'],
ratePerMin: 20,
},
{
name: 'device.getUserProfile',
scope: 'device',
desc: '当前登录用户的脱敏资料',
params: {
scene: { type: 'string', required: false, maxLength: 32, pattern: /^[A-Za-z0-9_-]+$/ },
},
redact: ['phone', 'idCard', 'realName', 'bankCard', 'token'],
ratePerMin: 10,
},
{
name: 'file.readText',
scope: 'file',
desc: '读取 H5 自己沙盒目录下的文本文件',
params: {
path: { type: 'string', required: true, maxLength: 160 },
},
redact: [],
ratePerMin: 30,
},
{
name: 'pay.invoke',
scope: 'pay',
desc: '拉起原生收银台,必须带订单号、金额与签名',
params: {
orderId: { type: 'string', required: true, maxLength: 40, pattern: /^[A-Za-z0-9_-]+$/ },
amount: { type: 'number', required: true, min: 0.01, max: 1000000 },
sign: { type: 'string', required: true, maxLength: 128 },
},
redact: ['sign'],
ratePerMin: 5,
needsConfirm: true,
},
{
name: 'location.get',
scope: 'location',
desc: '只返回城市/区级粗略位置',
params: {
precision: { type: 'string', required: false, enum: ['city', 'district'] },
},
redact: [],
ratePerMin: 10,
needsConfirm: true,
},
{
name: 'ui.toast',
scope: 'ui',
desc: '轻提示,纯展示能力',
params: { text: { type: 'string', required: true, maxLength: 40 } },
redact: [],
ratePerMin: 60,
},
];
/** 供容器侧 javaScriptProxy(methodList) 使用的静态导出清单 */
export const EXPORTED_METHODS: string[] = METHODS.map((m) => m.name);
/** 业务域名白名单:只允许自有 H5 域与容器内部页 */
const TRUSTED_HOSTS = ['m.yourdomain.com', 'app.yourdomain.com'] as const;
/** 容器内部页(离线包 / rawfile)没有 http origin,用伪协议标记后单独放行 */
const CONTAINER_ORIGIN = 'container://local';
export const resolveOrigin = (url: string): string => {
const trimmed = url.trim();
if (trimmed.startsWith('resource://') || trimmed.startsWith('file://') || trimmed.startsWith('about:')) {
return CONTAINER_ORIGIN;
}
// 只取 host,忽略端口、路径与 userinfo,避免 https://m.yourdomain.com@evil.com 这类混淆
const matched = /^(?:https?):\/\/([^/?#]+)/i.exec(trimmed);
if (!matched) {
return '';
}
const hostWithPort = matched[1];
const host = hostWithPort.split('@').pop()!.split(':')[0].toLowerCase();
return host;
};
const isOriginAllowed = (origin: string): boolean =>
origin === CONTAINER_ORIGIN || (TRUSTED_HOSTS as readonly string[]).includes(origin);
/** 参数校验:类型、必填、长度、字符集、枚举、区间,并拒绝未声明参数 */
注册表设计上有三个决定值得展开。
**决定一:方法名带域前缀,从命名上杀死通用入口。** `device.getDeviceInfo` 这种命名不只是好看——网关对 `/^(invoke|call|exec|reflect|__)/i` 的名字直接按"反射式调用"拒绝并单独告警。经验上,桥出事故十有八九是因为某个 `invoke(action, params)` 之类的通用入口:它的参数没有 schema 可言,`action` 是字符串就等于把路由权交给了 H5,审计日志里也只有一行含糊的 `invoke`。具名方法让每一次调用在日志里自解释。
**决定二:参数规格、频次上限、脱敏字段都长在注册表里,而不是散在实现里。** 这样"加一个方法"是一次声明式的配置变更,review 时一眼能看到这个方法能传什么、多快能调、回什么字段要打码。安全 review 成本从"读代码"降为"读表"。
这个决定也顺带回答了 H5 团队最初的顾虑——"白名单让需求变慢"。实际跑下来,加一个低敏方法(比如 `ui.toast`)就是往表里加一个条目,MR 里六项填全即可;真正要走安全评审的是 pay、file、location 这些高敏 scope,而它们本来就该慢。另外我们给网关的拒绝返回里加了可操作的提示(`device.getContacts 不在白名单`,而不是笼统的 error),H5 同学联调时能自己判断该走加白流程还是改调用,省掉了大量"桥是不是坏了"的沟通成本。注册表同时就是给 H5 团队的接口文档——文档和代码永远一致,这也是把规格"长在代码里"的额外红利。
**决定三:来源(origin)由容器解析,H5 只能自报一个不许信的值。** 解析逻辑在前一段的 `resolveOrigin` 里,有两个细节:
```text
https://m.yourdomain.com@evil.com/x → host 是 evil.com(userinfo 混淆)
resource://rawfile/bench/probe.html → container://local(容器内部页单独标记)
userinfo 混淆是老手法但至今好用:@ 前面的部分是用户信息,真正的 host 在 @ 后面。所以解析时 split('@').pop() 之后才取端口前的 host。容器内部页(离线包、rawfile)没有 https origin,我们用伪协议 container://local 显式标记后单独放行——这一步是踩过坑的,第一次联调离线包页面被网关全拒了,排查半小时发现是 origin 判定把内部页当成了空串拒绝(见第 6 章坑 3)。
5.2 参数校验与沙盒路径:两道最容易写松的关
文件第 2 段是参数校验和沙盒路径解析:
```typescript
const validateParams = (
spec: MethodSpec,
params: Record<string, unknown>,
): { ok: boolean; reason?: string } => {
for (const [key, rule] of Object.entries(spec.params)) {
const value = params[key];
if (value === undefined || value === null || value === '') {
if (rule.required) {
return { ok: false, reason: `缺少必填参数 ${key}` };
}
continue;
}
if (rule.type === 'number') {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return { ok: false, reason: `${key} 必须是有限数字` };
}
if (rule.min !== undefined && value < rule.min) {
return { ok: false, reason: `${key} 小于下限 ${rule.min}` };
}
if (rule.max !== undefined && value > rule.max) {
return { ok: false, reason: `${key} 超过上限 ${rule.max}` };
}
continue;
}
if (rule.type === 'boolean') {
if (typeof value !== 'boolean') {
return { ok: false, reason: `${key} 必须是布尔值` };
}
continue;
}
if (typeof value !== 'string') {
return { ok: false, reason: `${key} 必须是字符串` };
}
if (rule.maxLength !== undefined && value.length > rule.maxLength) {
return { ok: false, reason: `${key} 超过长度上限 ${rule.maxLength}` };
}
if (rule.pattern && !rule.pattern.test(value)) {
return { ok: false, reason: `${key} 命中非法字符` };
}
if (rule.enum && !rule.enum.includes(value)) {
return { ok: false, reason: `${key} 不在枚举范围` };
}
}
const unknownKeys = Object.keys(params).filter((k) => !(k in spec.params));
if (unknownKeys.length > 0) {
return { ok: false, reason: `存在未声明参数:${unknownKeys.join(', ')}` };
}
return { ok: true };
};
/** H5 沙盒根目录(真机上为应用 files 目录下的 h5 子目录) */
export const SANDBOX_ROOT = '/data/storage/el2/base/files/h5/';
/**
* 路径归一化:先解码(最多两次,挡住 %252e%252e),再统一分隔符,
* 最后按片段消解 . 与 ..,任何越界直接拒绝。
*/
export const resolveSandboxPath = (
relative: string,
root: string = SANDBOX_ROOT,
): { ok: boolean; resolved: string; reason?: string } => {
let decoded = relative;
for (let round = 0; round < 2; round += 1) {
let next: string;
try {
next = decodeURIComponent(decoded);
} catch {
return { ok: false, resolved: relative, reason: '非法百分号编码' };
}
if (next === decoded) {
break;
}
decoded = next;
}
const normalized = decoded.replace(/\\/g, '/').replace(/\/{2,}/g, '/');
if (normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized)) {
return { ok: false, resolved: normalized, reason: '禁止绝对路径或盘符' };
}
const segments: string[] = [];
for (const segment of normalized.split('/')) {
if (segment === '' || segment === '.') {
continue;
}
if (segment === '..') {
if (segments.length === 0) {
return { ok: false, resolved: normalized, reason: '路径穿越出沙盒' };
}
segments.pop();
continue;
}
segments.push(segment);
}
const resolved = `${root}${segments.join('/')}`;
if (!resolved.startsWith(root) || segments.length === 0) {
return { ok: false, resolved, reason: '解析结果不在沙盒目录内' };
}
return { ok: true, resolved };
};
参数校验里最值钱的规则是最后一条:**拒绝未声明参数**。常规校验只检查"该有的有没有、类型对不对",攻击者多塞一个字段没人管;但未声明参数正是参数走私的通道——今天多塞的 `debug: true` 没人消费,明天某个新实现顺手读了它,后门就通了。所以 schema 采用封闭世界假设:表里没有的字段就是非法的。
类型校验有个顺序细节:`undefined / null / ''` 先按"未填"处理(必填才报错),再进入类型分支。顺序反了的话,`typeof null === 'object'` 会产生"参数类型应为 string,实际为 object"这类误导性报错,排查时浪费时间。数字用 `Number.isFinite` 而不是 `typeof value === 'number'`,因为 `NaN`、`Infinity` 也是 number 类型——顺带说个 JSON 层面的坑:`JSON.stringify({amount: NaN})` 会静默变成 `{"amount":null}`,所以如果你们的调用参数是 H5 拼字符串传过来的,NaN 根本走不到网关就变成了 null,这正是我们先判空再判类型的原因之一。
路径校验 `resolveSandboxPath` 是文件类能力的生命线,流程是:**解码(最多两次)→ 统一分隔符 → 拒绝绝对路径/盘符 → 按片段消解 `.` 与 `..` → 前缀校验**。三个要点:
- **为什么要解码两次**:`%252e%252e%252f` 是 `../` 的二次编码。只解一次的话,第一轮得到 `%2e%2e%2f`,看起来不像 `..`,很容易被当普通文件名放行。上限设 2 是够用原则——业务上没有超过两层的合法编码,超过的直接拒绝(解码抛 `URIError` 也要 catch 住按拒绝处理,裸 `%` 是常见的 fuzz 输入)。
- **为什么要自己消解 `..` 而不是把结果再 `startsWith` 一下了事**:`h5/../../etc` 这种串在字符串层面就能通过前缀检查(它确实以 `h5/` 开头),只有按片段消解才能发现它越界。同时消解后 `segments.length === 0`(比如传 `.`)也拒绝,空路径没有业务意义,只会有探测意义。
- **反斜杠**:Windows 思维写出来的 `..\..\x` 在 POSIX 语义下不是穿越,但归一化统一替换成 `/` 之后它就是了。宁可错杀。
### 5.3 脱敏与确认令牌:数据出口的最后一道闸
文件第 3 段:
```typescript
```typescript
const masks: Record<string, (value: string) => string> = {
phone: (v) => v.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2'),
idCard: (v) => v.replace(/^(.{6}).*(.{4})$/, '$1********$2'),
bankCard: (v) => v.replace(/^(\d{4})\d+(\d{4})$/, '$1 **** **** $2'),
serial: (v) => v.replace(/^(.{4}).*(.{2})$/, '$1******$2'),
imei: (v) => v.replace(/^(.{4}).*(.{2})$/, '$1******$2'),
mac: (v) => v.replace(/^(.{4}).*(.{2})$/, '$1******$2'),
androidId: (v) => (v.length <= 6 ? '***' : `${v.slice(0, 3)}***${v.slice(-3)}`),
realName: (v) => (v.length <= 1 ? '*' : `${v[0]}${'*'.repeat(v.length - 1)}`),
token: (v) => (v.length <= 10 ? '***' : `${v.slice(0, 4)}***${v.slice(-3)}`),
sign: (v) => (v.length <= 10 ? '***' : `${v.slice(0, 4)}***${v.slice(-3)}`),
};
/** 敏感字段兜底规则:没写进 redact 的字段也可能带敏感信息,按名字兜一层 */
const SENSITIVE_KEY = /(phone|mobile|idcard|id_card|bankcard|token|secret|password|passwd|serial|imei|mac|realname)/i;
/**
* 递归脱敏:对象、数组都走一遍,最多 4 层,超出直接折叠。
* 返回新对象,绝不修改原生侧原始数据。
*/
export const redact = (value: unknown, fields: readonly string[], depth = 0): unknown => {
if (depth > 4) {
return '[truncated]';
}
if (Array.isArray(value)) {
return value.map((item) => redact(item, fields, depth + 1));
}
if (value && typeof value === 'object') {
const source = value as Record<string, unknown>;
const output: Record<string, unknown> = {};
for (const key of Object.keys(source)) {
const raw = source[key];
const hitExplicit = fields.includes(key);
const hitGuess = !hitExplicit && SENSITIVE_KEY.test(key) && typeof raw === 'string';
if ((hitExplicit || hitGuess) && typeof raw === 'string') {
const masker = masks[key] ?? masks[key.toLowerCase()];
output[key] = masker ? masker(raw) : '***';
continue;
}
output[key] = redact(raw, fields, depth + 1);
}
return output;
}
return value;
};
/** 简易摘要,用于日志与确认令牌绑定,不引入额外依赖 */
const digest = (input: string): string => {
let hash = 5381;
for (let i = 0; i < input.length; i += 1) {
hash = ((hash << 5) + hash + input.charCodeAt(i)) & 0xffffffff;
}
return (hash >>> 0).toString(36);
};
/**
* 原生弹窗确认通过后调用:把“方法名 + 参数摘要 + 时间”签成一次性令牌。
* 用户在弹窗里看到的是订单号与金额,不是 H5 自己拼的文案。
*/
export const issueConfirmToken = (
method: string,
params: Record<string, unknown>,
now: number,
nonce: string,
): string => `${method}:${digest(JSON.stringify(params))}:${now}:${nonce}`;
const TOKEN_TTL_MS = 120_000;
const checkConfirmToken = (
spec: MethodSpec,
params: Record<string, unknown>,
ctx: BridgeContext,
): { ok: boolean; reason?: string } => {
const token = ctx.confirmToken;
if (!token) {
return { ok: false, reason: '未获取用户确认' };
}
const parts = token.split(':');
if (parts.length !== 4) {
return { ok: false, reason: '确认令牌格式错误' };
}
const [tokenMethod, tokenDigest, issuedAtRaw] = parts;
if (tokenMethod !== spec.name) {
return { ok: false, reason: '确认令牌与调用方法不匹配' };
}
if (tokenDigest !== digest(JSON.stringify(params))) {
return { ok: false, reason: '确认后参数被改动' };
}
const issuedAt = Number(issuedAtRaw);
if (!Number.isFinite(issuedAt) || ctx.now - issuedAt > TOKEN_TTL_MS) {
return { ok: false, reason: '确认令牌已过期' };
}
if (ctx.consumedTokens.has(token)) {
return { ok: false, reason: '确认令牌已被使用(重放)' };
}
return { ok: true };
};
/** 原生能力适配器:网关只依赖接口,真机上由 ArkTS/原生模块实现 */
脱敏的实现是**递归 + 显式字段 + 名字兜底**三层。只处理一层对象是常见偷懒写法,但真实返回值里敏感数据经常藏在数组里(`list: [{name, phone}]`),所以对象和数组都要走。深度上限 4 层并返回 `[truncated]`,防止恶意构造的深嵌套对象把网关拖进性能陷阱。"名字兜底"是 fail-safe 设计:注册表里 `redact` 漏写的字段,只要名字命中敏感模式(`phone|token|secret|serial|...`),照样打码。新增字段默认安全,这比"新增字段默认泄露"重要得多。
确认令牌(`issueConfirmToken` / `checkConfirmToken`)解决的是"二次确认"被架空的问题。如果实现是"H5 调 `pay.invoke` → 原生弹窗 → 确认后执行",看起来没问题;但只要弹窗通过一次,很多实现会把"已确认"记成页面级状态,后续调用直接放行——攻击者只要诱导用户确认一次,就能连刷十次支付。我们的令牌做了四个绑定:**绑定方法名、绑定参数摘要(djb2)、绑定签发时间(120 秒 TTL)、一次性消费**。参数在确认后改一个字节,摘要对不上,拒绝;令牌用过一次,再拿来重放,拒绝(演示输出第 12 条用例就是这个场景)。用户在原生弹窗里看到的是订单号和金额原文,而不是 H5 拼好的文案,这一点同样重要:确认的对象必须是用户能理解的东西。
### 5.4 主流程:顺序本身就是设计
文件第 4 段是主流程:
```typescript
```typescript
export interface NativeBridgeAdapter {
getDeviceInfo(): Record<string, unknown>;
getUserProfile(scene?: string): Record<string, unknown>;
readTextFile(absolutePath: string): string;
invokePay(order: { orderId: string; amount: number; sign: string }): Record<string, unknown>;
getRoughLocation(precision: string): Record<string, unknown>;
toast(text: string): void;
}
/**
* 网关主流程。顺序即防线:任何一步失败立刻返回,不存在“失败降级放行”。
*/
export const createGateway = (
adapter: NativeBridgeAdapter,
options: { sandboxRoot?: string } = {},
) => {
const sandboxRoot = options.sandboxRoot ?? SANDBOX_ROOT;
const invoke = (
methodName: string,
params: Record<string, unknown>,
ctx: BridgeContext,
): BridgeResult => {
const audit: AuditStep[] = [];
const spec = METHODS.find((m) => m.name === methodName);
// 1. 白名单:不在表里就直接拒绝,且拒绝通用反射入口
if (!spec) {
const reflective = /^(invoke|call|exec|reflect|__)/i.test(methodName);
audit.push({
step: 'whitelist',
pass: false,
detail: reflective ? `拒绝反射式入口 ${methodName}` : `${methodName} 未注册`,
});
return {
ok: false,
code: 'WHITELIST_DENIED',
message: reflective
? 'Bridge 不提供通用调用入口,只能调用注册表中的具名方法'
: `方法 ${methodName} 不在白名单`,
audit,
redline: 'JS-01',
};
}
audit.push({ step: 'whitelist', pass: true, detail: `${spec.name} (${spec.scope})` });
// 2. 来源:用容器读取的页面 host,不信 H5 传参
if (!isOriginAllowed(ctx.origin)) {
audit.push({ step: 'origin', pass: false, detail: `拒绝来源 ${ctx.origin || '(unknown)'}` });
return {
ok: false,
code: 'ORIGIN_DENIED',
message: `页面 ${ctx.origin || '(unknown)'} 无权调用原生能力`,
audit,
redline: 'JS-02',
};
}
audit.push({ step: 'origin', pass: true, detail: ctx.origin });
// 3. 参数:类型/长度/字符集/枚举/未声明字段
const schema = validateParams(spec, params);
if (!schema.ok) {
audit.push({ step: 'schema', pass: false, detail: schema.reason ?? '参数非法' });
return {
ok: false,
code: 'SCHEMA_INVALID',
message: `参数非法:${schema.reason}`,
audit,
redline: 'JS-03',
};
}
audit.push({ step: 'schema', pass: true, detail: '通过' });
// 4. 频次:滑动窗口,按方法独立计数
const history = (ctx.history[spec.name] ?? []).filter((ts) => ctx.now - ts < 60_000);
if (history.length >= spec.ratePerMin) {
audit.push({
step: 'rate',
pass: false,
detail: `近 60s ${history.length} 次,上限 ${spec.ratePerMin}`,
});
return {
ok: false,
code: 'RATE_LIMITED',
message: '调用过于频繁,已限流',
audit,
redline: 'JS-05',
};
}
audit.push({ step: 'rate', pass: true, detail: `${history.length}/${spec.ratePerMin}` });
// 5. 二次确认:令牌绑定方法 + 参数摘要 + 有效期,且只能用一次
if (spec.needsConfirm) {
const confirm = checkConfirmToken(spec, params, ctx);
if (!confirm.ok) {
audit.push({ step: 'confirm', pass: false, detail: confirm.reason ?? '未确认' });
return {
ok: false,
code: ctx.confirmToken ? 'CONFIRM_INVALID' : 'NEED_CONFIRM',
message: confirm.reason ?? '该能力需要用户确认',
audit,
redline: 'JS-06',
};
}
audit.push({ step: 'confirm', pass: true, detail: '令牌校验通过' });
}
// 6. 执行:文件类先做沙盒路径解析,能力层只接受绝对路径
let data: unknown;
try {
switch (spec.name) {
case 'device.getDeviceInfo':
data = adapter.getDeviceInfo();
break;
case 'device.getUserProfile':
data = adapter.getUserProfile(params.scene as string | undefined);
break;
case 'file.readText': {
const resolved = resolveSandboxPath(String(params.path), sandboxRoot);
if (!resolved.ok) {
audit.push({ step: 'path', pass: false, detail: resolved.reason ?? '路径非法' });
return {
ok: false,
code: 'PATH_ESCAPE',
message: `路径被拒绝:${resolved.reason}`,
audit,
redline: 'JS-03',
};
}
audit.push({ step: 'path', pass: true, detail: resolved.resolved });
data = { content: adapter.readTextFile(resolved.resolved) };
break;
}
case 'pay.invoke':
data = adapter.invokePay({
orderId: String(params.orderId),
amount: Number(params.amount),
sign: String(params.sign),
});
// 令牌一次性:用掉即作废
if (ctx.confirmToken) {
ctx.consumedTokens.add(ctx.confirmToken);
}
break;
case 'location.get':
data = adapter.getRoughLocation(String(params.precision ?? 'city'));
if (ctx.confirmToken) {
ctx.consumedTokens.add(ctx.confirmToken);
}
break;
case 'ui.toast':
adapter.toast(String(params.text));
data = { shown: true };
break;
default:
// 注册表与 switch 不同步时宁可报错,也不静默放行
return {
ok: false,
code: 'WHITELIST_DENIED',
message: '方法已注册但未实现,拒绝调用',
audit,
redline: 'JS-01',
};
}
} catch (error) {
// 能力层异常统一收敛,绝不把堆栈或路径回传给 H5
audit.push({
step: 'execute',
pass: false,
detail: error instanceof Error ? error.name : 'unknown',
});
return { ok: false, code: 'SCHEMA_INVALID', message: '原生能力执行失败', audit };
}
// 7. 出口脱敏:只回传允许的字段,且已打码
const redacted = redact(data, spec.redact);
audit.push({ step: 'redact', pass: true, detail: spec.redact.join(', ') || '无敏感字段' });
return {
ok: true,
code: 'OK',
message: `${spec.name} 调用成功`,
data: redacted,
audit,
};
};
return { invoke };
};
主流程的顺序本身就是设计:**白名单 → 来源 → 参数 → 频控 → 确认 → 路径 → 执行 → 脱敏**。顺序不是随意的——便宜的检查放前面(白名单是查表),带副作用的放最后(执行)。这既是性能考虑(被拒绝的调用不会走到能力层),也是日志语义的考虑:审计链的前几关就告诉你这次调用"是谁、从哪来、想干什么",出了事溯源时不需要读完整个链路。更重要的是**任何一步失败立即返回,不存在"校验失败但记录一下继续执行"的降级路径**。我们审计过的一些实现里有"白名单未命中就打日志放行"的灰度逻辑,灰度期一过忘了删,就是事故。网关里可以留灰度开关,但必须是显式的 dry-run 模式(只审计不拦截,返回前强制走完整校验并打标),且带过期时间。
还有一个容易被忽略的细节:`switch` 的 `default` 分支返回的是拒绝而不是抛异常。注册表和执行分支是两份代码,加方法时完全可能"表里注册了、执行分支忘了写",这种情况宁可明确拒绝(`方法已注册但未实现`),也不能落进一个含糊的 undefined 返回——H5 拿到 undefined 会当成"调用成功但没数据",问题就藏起来了。
审计日志(`audit` 数组)记录的是每个关卡的通过情况与判定依据。注意它**不记录参数原始值**——日志系统是经常被遗忘的泄露面,把 H5 传来的参数原样打进日志,等于把攻击面复制了一份到日志平台。要记就记方法名、参数键名、值长度、摘要。
### 5.5 能力层与 13 条用例
`native.ts` 是能力层的演示实现(桌面端用 Node 文件系统模拟沙盒,真机上换成 ArkTS 的 `fileIo`,接口不变):
```typescript
```typescript
/**
* 原生能力层(演示实现)
*
* 真机上这一层由 ArkTS / 原生模块实现;桌面端用 Node 的文件系统模拟,
* 目的是让网关可以脱离设备直接跑起来、可回归。
* 注意:能力层只做“取数据 / 执行动作”,不做任何鉴权,鉴权全部在网关。
*/
import { existsSync, readFileSync } from 'node:fs';
import type { NativeBridgeAdapter } from './gateway';
/** 演示数据:包含真实业务里必然会出现的敏感字段 */
const DEVICE = {
model: 'TLR-AL00',
system: 'OpenHarmony 7.0.0.105',
appVersion: '1.0.0',
serial: '8HEPM19529001234',
imei: '868123045678901',
mac: 'A45E608B2C11',
androidId: 'ohos-9f2a71c4e8b0',
};
const PROFILE = {
nickname: '林舟',
realName: '林舟',
phone: '13800138000',
idCard: '110101199003071234',
bankCard: '6222020200112345678',
memberLevel: '黄金',
token: 'eyJhbGciOiJIUzI1NiJ9.eyJ1aWQiOjEwMDg2fQ.sig',
};
export class DemoAdapter implements NativeBridgeAdapter {
private paidOrders = new Set<string>();
getDeviceInfo(): Record<string, unknown> {
// 真机:deviceInfo.productModel / osFullName + 业务侧设备指纹
return { ...DEVICE };
}
getUserProfile(scene?: string): Record<string, unknown> {
// scene 只用于埋点,不参与数据查询,避免 H5 用它越权取别人的资料
return { ...PROFILE, scene: scene ?? 'default' };
}
readTextFile(absolutePath: string): string {
// 网关已经保证 absolutePath 落在沙盒内,这里仍做一次存在性判断
if (!existsSync(absolutePath)) {
throw new Error('ENOENT');
}
return readFileSync(absolutePath, 'utf8');
}
invokePay(order: { orderId: string; amount: number; sign: string }): Record<string, unknown> {
// 真实实现:校验订单签名 → 拉起收银台 → 回调结果
if (this.paidOrders.has(order.orderId)) {
throw new Error('ORDER_DUPLICATED');
}
this.paidOrders.add(order.orderId);
return { orderId: order.orderId, amount: order.amount, status: 'PAID', sign: order.sign };
}
getRoughLocation(precision: string): Record<string, unknown> {
// 只回传城市/区级,坐标在原生侧就已经丢弃
return precision === 'district'
? { city: '深圳市', district: '南山区' }
: { city: '深圳市' };
}
toast(text: string): void {
console.log(` [原生 Toast] ${text}`);
}
}
`demo.ts` 是 13 条用例的演示入口,也是 CI 回归:
```typescript
```typescript
/**
* 演示入口:不接设备也能把网关的每一条防线跑一遍。
* 运行:npm run demo:bridge
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import {
METHODS,
createGateway,
issueConfirmToken,
resolveOrigin,
type BridgeContext,
type BridgeResult,
} from './gateway';
import { DemoAdapter } from './native';
const SANDBOX = resolve(__dirname, '..', 'sandbox');
const H5_ROOT = join(SANDBOX, 'h5');
/** 准备演示文件:沙盒内一份正常文件,沙盒外一份“不该被读到”的文件 */
const prepareFiles = (): void => {
mkdirSync(H5_ROOT, { recursive: true });
writeFileSync(join(H5_ROOT, 'notice.txt'), 'order=SO20261104001\nstatus=READY\n', 'utf8');
writeFileSync(join(SANDBOX, 'app-secret.txt'), 'DEMO_ONLY=never-expose\n', 'utf8');
};
const gateway = createGateway(new DemoAdapter(), { sandboxRoot: `${H5_ROOT}/` });
/** 每次演示新建上下文,模拟一次页面会话 */
const newContext = (origin = 'https://m.yourdomain.com'): BridgeContext => ({
origin: resolveOrigin(origin),
now: Date.now(),
history: {},
consumedTokens: new Set<string>(),
});
/**
* 记录一次调用:把调用时间写进滑动窗口,再打印结果与审计轨迹。
* 注意日志里只打印方法名与返回摘要,不打印原始参数值。
*/
const call = (
label: string,
ctx: BridgeContext,
method: string,
params: Record<string, unknown>,
expect: string,
): BridgeResult => {
const result = gateway.invoke(method, params, ctx);
const history = ctx.history[method] ?? [];
history.push(ctx.now);
ctx.history[method] = history;
const flag = result.code === expect ? 'PASS' : 'FAIL';
console.log(`\n[${flag}] ${label}`);
console.log(` 调用 ${method}(${Object.keys(params).join(', ') || '无参数'})`);
console.log(` 结果 ${result.code} · ${result.message}`);
if (result.data !== undefined) {
console.log(` 返回 ${JSON.stringify(result.data)}`);
}
result.audit.forEach((step) => {
console.log(` ${step.pass ? '✓' : '✗'} ${step.step}: ${step.detail}`);
});
return result;
};
const main = (): void => {
prepareFiles();
console.log('='.repeat(72));
console.log(`JSBridge 网关演示 · 注册方法 ${METHODS.length} 个:${METHODS.map((m) => m.name).join(', ')}`);
console.log(`导出给容器的 methodList:${METHODS.map((m) => m.name).join(', ')}`);
console.log('='.repeat(72));
const ctx = newContext();
call('1. 正常调用:设备信息(返回体已打码)', ctx, 'device.getDeviceInfo', {}, 'OK');
call('2. 正常调用:用户资料(scene 只影响埋点)', ctx, 'device.getUserProfile', { scene: 'order-detail' }, 'OK');
call('3. 越权:读通讯录(未注册方法)', ctx, 'device.getContacts', {}, 'WHITELIST_DENIED');
call('4. 反射:__invoke 通用入口', ctx, '__invoke', { target: 'Runtime', method: 'exec' }, 'WHITELIST_DENIED');
call('5. 来源伪造:第三方域名页面调用', newContext('https://evil.example.net/promo'), 'device.getDeviceInfo', {}, 'ORIGIN_DENIED');
call('6. 参数污染:多塞一个未声明字段', ctx, 'ui.toast', { text: 'hi', extra: 'x' }, 'SCHEMA_INVALID');
call('7. 路径穿越:../../app-secret.txt', ctx, 'file.readText', { path: '../../app-secret.txt' }, 'PATH_ESCAPE');
call(
'8. 二次编码穿越:%252e%252e%252fapp-secret.txt',
ctx,
'file.readText',
{ path: '%252e%252e%252fapp-secret.txt' },
'PATH_ESCAPE',
);
call('9. 沙盒内正常读取', ctx, 'file.readText', { path: 'notice.txt' }, 'OK');
// 10. 支付:未确认 → 确认 → 重放同一个令牌
const payParams = { orderId: 'SO20261104001', amount: 199, sign: 'SIGN_9F2A1C7B5D3E4F60' };
call('10. 支付:没有用户确认', ctx, 'pay.invoke', payParams, 'NEED_CONFIRM');
ctx.confirmToken = issueConfirmToken('pay.invoke', payParams, Date.now(), 'n-7f3a');
call('11. 支付:用户确认后执行', ctx, 'pay.invoke', payParams, 'OK');
call('12. 支付:重放上一次的确认令牌', ctx, 'pay.invoke', payParams, 'CONFIRM_INVALID');
// 13. 频控:pay.invoke 上限 5 次/分钟,这里连打 6 次触发限流
const rateCtx = newContext();
let lastCode = '';
for (let i = 0; i < 6; i += 1) {
const orderId = `SO2026110400${i + 2}`;
const amount = 1;
const sign = 'SIGN-DEMO';
rateCtx.confirmToken = issueConfirmToken('pay.invoke', { orderId, amount, sign }, Date.now(), `n${i}`);
lastCode = gateway.invoke('pay.invoke', { orderId, amount, sign }, rateCtx).code;
const history = rateCtx.history['pay.invoke'] ?? [];
history.push(rateCtx.now);
rateCtx.history['pay.invoke'] = history;
}
console.log(`\n[${lastCode === 'RATE_LIMITED' ? 'PASS' : 'FAIL'}] 13. 频控:1 分钟内第 6 次支付调用`);
console.log(` 结果 ${lastCode} · 已按方法维度限流`);
console.log(`\n${'='.repeat(72)}\n演示结束:期望的拒绝码全部命中即为通过。\n`);
};
main();
最小复现路径(在仓库根目录):
```bash
npm install # 首次安装依赖
npm run demo:bridge # 编译并运行 13 条网关用例
6. 踩坑实录:12 个,全部来自这次改造
这一节可能是全文最值钱的部分。12 个坑全部来自真实报错和排查记录,按"容器与 API / 策略与设计 / 工程与工具链"分三组,每条都是"现象 → 根因 → 处理"的格式。我们养成的习惯和很多同学一样:每踩一个坑立刻把现象、排查动作和最终解法记进仓库的证据目录,这份清单就是从里面整理出来的——有好几个坑的表象和根因隔着好几层,不记下来第二天自己都想不起来当时是怎么定位的。
容器与 API 组
坑 1|javaScriptProxy 参数不支持更新,动态 methodList 是幻觉。
现象:页面切换时给 methodList 数组 push 新方法,H5 侧 typeof NativeBridge.newMethod 永远是 undefined。根因:官方文档明确该组件参数不支持更新(参考资料 [1])。处理:白名单绑定到容器实例,页面维度差异在网关注册表里用 scope 解决;确实要换白名单,重建 Web 组件。别跟这个行为较劲,它帮你看住了"运行时扩权"。
坑 2|未导出方法的调用表现是 throw,不是静默返回。
现象:加固后跑探针,NativeBridge.readSecretFile 直接不存在,探针的 typeof 检查走到"方法未导出"分支;但 H5 直接 NativeBridge.readSecretFile(...) 时,控制台抛出的是 TypeError(“…… is not a function”),而不是返回 undefined。这意味着 H5 侧的兼容代码要包 try/catch,前端 SDK 要在网关返回码之外再处理一层"容器未导出"。这是三层结构必然的代价:H5 看到的错误形态有两种(容器层未导出、网关层拒绝),错误信封要统一(见坑 4)。
坑 3|离线包页面的 origin 是空/伪协议,来源校验第一版把自家页面全拒了。
现象:联调时所有内部页面调桥全挂,resolveOrigin 返回空串,isOriginAllowed('') 拒绝。处理:显式识别 resource://、file://、about: 前缀,映射为 container://local 后单独放行。但注意不能无脑放行所有内部页——离线包必须先过签名校验(属于另一个场景,离线包防篡改,仓库里有独立实现),签名不过的内部页在网关里同样要拒。
坑 4|同步桥返回值只能是 string/number/boolean,对象要过 JSON 信封。
现象:undefined 序列化后到 H5 手里是字符串 "undefined",JSON.parse 直接抛 SyntaxError;抛出的 Error 对象 stringify 后变成 {},堆栈信息全丢。处理:网关约定统一信封 {ok, code, message, data},永远返回字符串化的 JSON;异常在网关内收敛成 code,绝不把堆栈、绝对路径回传 H5——错误信息本身就是信息泄露。
坑 5|onLoadIntercept 对重定向也触发,来源校验必须是每次调用级而不是页面级。
现象:实测台复测时拦截到一条计划外的日志——白名单域名 m.yourdomain.com 的页面发出了到 m.bedpage.com 的重定向跳转,被 onLoadIntercept 拦下。根因:页面内容可以被重定向污染,之后发起的桥调用如果只信首次来源就漏了。处理:网关在每次 invoke 里都带当前页面 URL 重新解析 origin,容器侧在每次导航回调里刷新这个值。
坑 6|decodeURIComponent 会抛异常,路径解码必须 try/catch。
现象:裸 %、%zz 这类非法编码抛 URIError: URI malformed。不做 catch 的话,攻击者用一个畸形路径就能让网关抛异常——取决于调用链,异常可能炸掉这次调用,也可能被上层兜底成放行。处理:解码抛异常 = 拒绝,宁可错杀。
坑 7|频控用 Date.now() 会被系统时间回调打穿。
现象:演示代码用 Date.now() 保持零依赖,但用户手动改时间、NTP 校时都会让滑动窗口失真(回拨 1 小时 = 计数清零)。处理:生产建议用单调时钟(Node 侧 process.hrtime.bigint(),ArkTS 侧 systemDateTime 的 uptime 类接口),或者在服务端按设备维度再计一层。频控防的是"批量刷取",本地频控是第一道,服务端频控才是底。
工程与工具链组
坑 11|真机白屏两次:appKey 没注册、原生包缺失。
现象:HAP 装上真机启动后白屏,hilog 里抓到 RN 的经典报错:
Invariant Violation: Application "AwesomeProject" has not been registered.
根因一:壳工程请求的 appKey 是 AwesomeProject,我们的 index.js 只注册了 WebViewSecurityLab——AppRegistry.registerComponent 的名字必须和壳侧请求的名字一字不差,注册两个也不冲突,处理成双注册解决。根因二:App.tsx 引了 react-native-safe-area-context,但预编译壳里没有这个原生包,JS 侧解析直接失败——同样是白屏,hilog 里却是完全不同的报错。教训:RN 的白屏从来不是一个病,先看 hilog 再动手,两个根因能长得一模一样。
坑 12|构建与采集链路的三个小坑。
一是 hdc install -r 报签名校验失败:调试 profile 和 bundleName 绑定,实测台 HAP 的 bundleName 必须沿用壳工程的 com.rnoh.awesomeproject,改成新名字就装不上。二是全量构建在命令行挂起(CMake 配置阶段的外部调用被安全软件拦截,加上大目录 I/O 极慢),改用"预编译壳 + Metro 下发 JS"的调试链路才把节奏救回来;oh_modules 整目录复制又慢又不完整,换成目录联接(junction)共享一份依赖。三是主机侧采集:hdc file recv 在 Git Bash 下会被路径转换改坏参数,cd 到目标目录用相对路径、并设 MSYS2_ARG_CONV_EXCL='*';应用沙盒文件拉不出来时,改走 console.info + hilog -x 的日志通道——第 2 章的原始数据就是这条链路采的。
7. 架构总览:一次调用走完全程
把前面所有角色放到一张图上。从 H5 的一行 JS 到原生能力执行完毕,一条调用要穿过五个边界,每一层只回答一个问题:
┌────────────────────────────────────────────────────────────────┐
│ H5 页面(probe.html / 业务 H5) │
│ window.NativeBridge.device.getUserProfile({...}) │
│ —— 只能看到 methodList 导出的方法名,其余方法连对象都不存在 │
├──────────── 边界 1:容器导出层(javaScriptProxy)────────────────┤
│ ArkWeb 容器(Bench.ets / 生产壳) │
│ methodList:从注册表推导,静态确定,组件创建后不可变 │
│ onLoadIntercept:导航先过 urlGuard,重定向同拦 │
│ onSslErrorEventReceive:安全模式一律 handleCancel │
├──────────── 边界 2:策略网关层(gateway.ts,本文核心)────────────┤
│ 白名单 → 来源 → 参数 schema → 频控 → 确认令牌 → 沙盒路径 │
│ 任何一步失败立即返回统一信封,不存在降级放行 │
├──────────── 边界 3:数据出口层(redact)─────────────────────────┤
│ 递归脱敏(显式字段 + 名字兜底),深度上限 4 层 │
├──────────── 边界 4:能力适配层(NativeBridgeAdapter)────────────┤
│ 桌面端:Node fs 模拟沙盒;真机端:ArkTS fileIo 等系统 API │
│ —— 只做"取数据、执行动作",不做任何鉴权 │
├──────────── 边界 5:系统层(HarmonyOS API 26)───────────────────┤
│ ArkWeb / fileIo / deviceInfo / packageInfo │
└────────────────────────────────────────────────────────────────┘
这个分层里最容易搞混的是边界 2 和边界 4 的职责分界。能力适配层拿到的一定是"已经通过全部校验的调用"——路径是解析后的沙盒内绝对路径,参数是校验过的类型,所以它不需要也不允许再做任何 if 判断。反过来,任何一次"为了方便"在能力层里写的鉴权逻辑,都是网关职责的泄漏:同一份规则从此存在两处,改一处忘一处。
11 个实验台页面与安全模块的对应关系如下,每个页面都是"同一份策略"的一个观察窗口:
| 页面 | 对应模块 | 观察什么 |
|---|---|---|
| 首页(红线自检面板) | webviewConfig.ts | 15 项容器开关、33 条红线的命中与评分 |
| 场景 01 业务内嵌 H5 | webviewConfig.ts | 文件域/混合内容/多窗口等能力边界 |
| 场景 02 JSBridge | bridge.ts + gateway.ts | 白名单、参数、脱敏、频控、确认、审计链 |
| 场景 03 本地资源与离线包 | offlinePackage.ts | file 域隔离、解压穿越、热更验签 |
| 场景 04 外部链接 | urlGuard.ts | 精确域名匹配、scheme/端口白名单、系统浏览器兜底 |
| 场景 05 Cookie/存储 | cookiePolicy.ts | HttpOnly/Secure/SameSite、退出清理 |
| 场景 06 调试开关 | webviewConfig.ts(debugEnabled) | 远程调试旁路的开关策略 |
| 场景 07 证书校验 | certificate.ts | SSL 错误处理、证书锁定 |
| 场景 08 能力回调 | permissionGate.ts | 摄像头/定位/文件选择的一次一授权 |
| 场景 09 发布门禁 | report.ts | 构建期扫描、红线卡点、巡检报告 |
| 巡检报告页 | report.ts | 全部实验的发现汇总与导出 |
7.1 ArkTS 容器侧:导出层是怎么接线的
策略全部在 TS 侧讲完了,容器侧(harmony/entry/src/main/ets/pages/Bench.ets)的接线只有三段,每段都值得逐行看。第一段是导出列表——注意它直接由模式开关推导,而模式又决定了容器的一切配置:
private methodList(): string[] {
if (this.mode === 'insecure') {
// 危险配置:readSecretFile / execCommand 也导出,探针据此得手
return ['report', 'getProbePath', 'getDeviceInfo', 'readSecretFile', 'execCommand'];
}
// 安全基线:能力方法根本不在导出列表里,H5 侧 typeof 判断直接为 false
return ['report', 'getProbePath', 'getDeviceInfo'];
}
第二段是 Web 组件链式配置——同一个 HAP 里,两套配置的差异全部收敛在这一个 build 函数里,这保证了 2.4 节"唯一变量"的成立:
Web({ src: 'about:blank', controller: this.controller })
.fileAccess(this.mode === 'insecure') // 危险配置才开文件访问
.domStorageAccess(this.mode === 'insecure') // DOM 存储同理
.geolocationAccess(this.mode === 'insecure') // 定位能力同理
.mixedMode(this.mode === 'insecure' ? MixedMode.All : MixedMode.None)
.javaScriptProxy({
object: this.bridge, // 桥对象,方法内不做鉴权
name: 'NativeBridge', // H5 侧 window.NativeBridge
methodList: this.methodList(), // 导出列表:唯一的能力入口
controller: this.controller
})
第三段是导航拦截与证书处理——实测台里 onLoadIntercept 做的是精确后缀匹配 + 危险配置直通,生产版请用仓库 urlGuard.ts 的完整判定(scheme 白名单、userinfo 混淆防护、端口校验):
.onLoadIntercept((event) => {
const url = event.data.getRequestUrl();
// 容器内部页(rawfile / about:blank)放行,不走白名单
if (url.indexOf('resource://') === 0 || url.indexOf('about:') === 0) {
return false;
}
if (this.mode === 'insecure') {
console.info('[BENCH_NAV]ALLOW ' + url); // 危险配置:只记录不拦截
return false;
}
// 演示用简化判定;生产版用 urlGuard.decideLink 的完整规则
const ok = url.indexOf('yourdomain.com') > 0;
if (!ok) {
console.info('[BENCH_NAV]BLOCK ' + url); // 拦截且落审计日志
this.navBlocked++;
return true; // 取消本次导航
}
console.info('[BENCH_NAV]ALLOW ' + url);
return false;
})
.onSslErrorEventReceive((event) => {
// 证书错误:危险配置 proceed(=中间人可乘),安全基线一律 cancel
if (this.mode === 'insecure') {
event.handler.handleConfirm();
} else {
event.handler.handleCancel();
}
})
第 6 章坑 12 说过,hdc file recv 在 Git Bash 下会被路径转换改坏参数,所以实测数据的回传不走文件通道,而是桥对象回传 + hilog 打标:
// 桥方法 report:探针结果 JSON 回传原生,立即打标输出
report(data: string): void {
console.info('[BENCH_RESULT]' + data); // 主机侧:hdc shell hilog -x | grep BENCH_RESULT
}
这条"无 root 采集链路"是整个实测方案的咽喉:探针在 WebView 里执行 → window.NativeBridge.report(JSON.stringify(...)) → ArkTS 侧 console.info → hilog → 主机 grep。采集到的每一行都能追溯到具体设备(日志里带 deviceInfo.productModel)。
7.2 RN 侧:React 页面怎么消费这套策略
src/security/ 下的模块是策略源,src/screens/ 下的页面只是策略的"可视化"。以 BridgeScreen 为例,页面上"危险 / 安全"的切换,本质是 LabContext 里一个全局配置对象在 INSECURE_CONFIG 与 BASELINE_CONFIG 之间切换,然后把当前配置喂给 evaluateWebViewConfig() 得到发现列表。页面本身没有任何一行安全逻辑——这保证了演示与真实策略永远一致,页面只是把 evaluateWebViewConfig 的返回渲染成卡片和表格。这也是我们建议的接线方式:如果你的工程也要做类似的安全面板,让页面依赖模块的纯函数,而不是在页面里复制规则。
8. 十一个实验台逐一详解:每个页面证明一条红线
Demo 把安全能力拆成 9 个场景实验台 + 2 块门禁面板,共 11 个页面。逐个说明"它证明什么、怎么证明、边界在哪"。
首页 · 红线自检面板。 进入 App 的第一屏就是一次全量体检:容器风险评分 0–100、命中问题数、严重/高危计数、红线总览。33 条红线里 26 条是阻断项——阻断项未通过时,页面会明确标出"禁止出生产包"。这个面板的存在感比任何文档都强:打开 App 就知道当前容器配置离基线有多远。
场景 01 · 业务内嵌 H5 页面(严重,6 条红线)。 演示"为了兼容老页面放开能力开关"的代价。页面可以反复切换危险/安全两种配置,对应 allowUniversalAccessFromFileURLs、allowFileAccess、混合内容等 6 个开关。关键认知:WebView 一旦同时具备"加载远端页面"和"读本地文件"两种能力,等于把沙箱交给了攻击者——活动页引一个三方 SDK 就够了。
场景 02 · JSBridge(严重,5 条红线,本文主角)。 桥接安全网关的完整控制台:选样本、发起调用、看审计链。上面 2.2 节和第 5 章已经把它的实现讲透,这里补充一个使用视角——每次调用的审计链是逐关卡展开的(whitelist → origin → schema → rate → confirm → path → redact),测试同学不需要读代码就能判断"这次调用为什么被拒",这是把安全能力做成可观测产品而不是黑盒的价值。
场景 03 · 本地静态资源与离线包(严重,4 条红线)。 三个高频事故点:file 域跨源读沙盒、解压 zip 时不校验 ../ 穿越路径、热更新只比版本号不验签。第三点在发版节奏上尤其致命——离线包是热更的,验签不做的离线包等于"把原生防线的钥匙挂在热更通道上"。
场景 04 · 打开外部第三方链接(高危,4 条红线)。 urlGuard.ts 的完整判定链:scheme 白名单(https 才内嵌,javascript:/data:/file:/intent: 等一律 BLOCK)→ 域名精确匹配(拒绝 yourdomain.com.evil.com 和 userinfo 混淆)→ 端口白名单 → 白名单外系统浏览器打开。2.2 节指标 4 里那条"计划外的重定向拦截"就发生在这个实验台的实测里。
场景 05 · Cookie / 存储 / 缓存(高危,3 条红线)。 会话 Cookie 的 HttpOnly/Secure/SameSite 三件套、localStorage 明文存 token、退出登录不清缓存。最后一条最容易被低估:退出清理不是"清内存态",而是清 WebView 的 Cookie、表单数据、缓存分区——共享设备的场景下(客服工位、测试机),残留就是泄露。
场景 06 · 调试开关(严重,2 条红线)。 红线条数最少,但每条都是硬的:setWebDebuggingAccess(true) 写死在 Application 级别 = 整台设备开了一条 DevTools 注入通道;测试包与生产包共用构建变体 = 调试开关靠人记。这条红线和桥的关系在第 3 章"限制二"里说过:桥的安全结论以调试关闭为前提。
场景 07 · HTTPS 证书校验(严重,3 条红线)。 onReceivedSslError 无条件 proceed 是"为绕过测试环境自签证书"埋下的经典雷,上线后中间人可解密全部流量。实验台演示了三种危险形态(无条件 proceed、trustAllCerts、只校验链不校验域名)和正确的生产姿势:cancel + 核心域名证书锁定 + 备用 Pin(Pin 过期导致全量不可用,是最常见的被迫关校验原因)。
场景 08 · Web 能力回调(高危,3 条红线)。 摄像头、定位、文件选择这类"容器能力回调"的二次授权:onPermissionRequest 无条件 grant 是事故形态;正确姿势是来源域名白名单 + 系统权限已获取 + 显式弹窗一次一授权三条件同时满足,并且授权结果落审计——第 5 章的确认令牌是同一个思想在"容器回调"上的复用。
场景 09 · 发布门禁与自检(中危,3 条红线)。 红线不能只靠人肉 review:CI 里做静态扫描(危险 API、开关、证书逻辑),生产包做运行时断言,巡检结果沉淀成 report.ts 输出的可追溯报告。这个实验台是前 8 个场景的"制度化出口"——前面所有技术手段,最终都要落到"机器拦、人看报告"的流程上才可持续。
巡检报告页。 全部实验产生的发现(Finding)汇总:按场景聚合、按严重度排序、支持导出。它和首页自检面板的区别是视角:首页回答"当前配置怎么样",报告页回答"这几轮实验发现了什么、修了没有"。
11 个页面共用一套"危险 / 安全"切换交互,这个设计不是演示技巧:同一个页面、同一份代码、只切配置,才能把"差异来自配置"这件事变得可证伪。如果安全效果依赖两套不同的代码路径,说服力就塌了。
9. FAQ
Q1:网关放在 TS 侧,性能够吗?真机上要不要换 ArkTS 重写?
不需要。单次调用的开销在亚毫秒级,瓶颈从来不在校验逻辑。放在 TS 侧的收益是三端同源(鸿蒙/Android/iOS 共用同一份策略)+ 桌面可测(demo:bridge 不接真机就能回归)。只有当某个方法需要调用纯 ArkTS API 时,才在能力适配层下沉到原生,策略层不动。
Q2:H5 团队抱怨白名单磨平了灵活性,怎么办?
把"加白"做成流程而不是例外:注册表条目即文档,MR 模板六项填全(方法名、scope、参数规格、脱敏字段、频次上限、是否确认)。低敏方法当天合入,高敏方法走安全评审——本来就该慢的是 pay 和 location,不是 toast。
Q3:为什么不直接用 ArkWeb 的 permission 参数做 URL 级白名单?
它是很好的第四道保险,但表达力止步于"哪个 URL 能碰哪个方法":参数规则、频次、脱敏、审计都表达不了。两者叠加而不是二选一——容器侧收窄 URL,网关管住内容和行为。
Q4:这套方案对 RN 版本有依赖吗?
网关是纯 TS,零 RN 依赖,RN 0.7x/0.8x 都能跑。容器侧的 onLoadIntercept、javaScriptProxy 是 ArkWeb 的能力,和 RN 版本解耦;本文验证的环境是 RN 0.84.1 + @react-native-oh/react-native-harmony 0.84.1 + API 26。
Q5:探针会一直加下去吗?攻击面怎么持续覆盖?
探针按攻击面分类维护:越权调用、反射入口、来源伪造、参数污染、路径编码、确认缺失、令牌重放、频控触发。新增探针 = 新增一条 demo.ts 用例 + 一条真机探针,接进 CI。比探针更重要的是 2.6 节列的边界——明确知道自己没测什么,比声称"全量覆盖"诚实得多,也有用得多。
10. 结论与可直接复用的建议
交付清单:纯 TS 网关(注册表、参数校验、沙盒路径、频控、确认令牌、递归脱敏)、13 条 CI 用例、真机实测台与全部原始数据,全部开源在文末仓库。回归状态:tsc --noEmit 无错误、41 条 Jest 全绿、demo:bridge 13/13 PASS。
给要做同样事情的同学四条建议:
1. 落地顺序按性价比排,别一次到位。
- 先做注册表 + methodList 静态同源(半天工作量,直接消灭通用入口和未注册方法);
- 加参数 schema 校验与"拒绝未声明参数"(一天,含存量方法补规格);
- 加返回值递归脱敏(一天,先覆盖 device/pay/user 三类高敏方法);
- 加来源校验与审计日志(半天,注意内部页 origin 的处理);
- 最后做频控与二次确认令牌(两天,需要原生弹窗配合)。
2. 四条底线写进团队 Wiki,一条都不能松。
校验失败不降级放行,要灰度就做显式 dry-run 并带过期时间;白名单只定义在原生侧,H5 侧任何"方法可用性"信息只能读不能写;错误信封统一,异常信息不回传 H5,日志不记参数原文;确认令牌一次一用,绑定方法与参数摘要。
3. 用 CI 盯住攻击面,用两个计数器盯住线上。
npm run demo:bridge 的 13 条用例接进 CI,覆盖越权、反射、来源伪造、参数污染、两级路径编码、确认缺失、令牌重放、频控触发八类攻击面;线上监控盯 WHITELIST_DENIED 突增(可能是有页面在探测你的桥)和 RATE_LIMITED 突增(可能是脚本化调用)。每次给注册表加方法,走同一个 MR 模板:方法名、scope、参数规格、脱敏字段、频次上限、是否需要确认,六项填全才能合入。
4. 诚实标注版本与边界。
我们的 @react-native-oh/react-native-harmony 是 0.84.1,设备是 OpenHarmony 7.0.0.105(API 26)——写文章、发仓库都如实标注,审稿人和接手的同事都会感谢这份诚实。桥的安全结论永远附带前提:容器配置基线通过自检、调试开关关闭、离线包签名校验通过。
最后的判断:JSBridge 的安全不是"加不加"的问题,而是"准入权在谁手里"的问题。把准入权从 H5 收回原生,用注册表把"能做什么"显式化,用网关把"怎么调、怎么回"管起来——这套结构在真机上验证过越权面 4/6 → 0/6,也没有付出可感知的性能代价。它不依赖某个特定容器版本,纯 TS 的网关可以跟着你们的 RN 0.84 工程直接走,鸿蒙、Android、iOS 三端共用同一份策略定义,只是各端的导出层 API 不同而已。往后我们打算把 WebView 安全面按场景逐篇补齐(离线包防篡改、Cookie 与存储治理、证书校验),下一篇见。
环境信息与引用
| 项 | 版本/型号 |
|---|---|
| DevEco Studio | 26.0.0.851(Build DS-261.23567.138.36.2600851) |
| compileSdkVersion / compatibleSdkVersion | 26.0.0 / 5.0.5(17) |
| React Native | 0.84.1(react 19.2.3) |
| @react-native-oh/react-native-harmony | 0.84.1 |
| TypeScript / Node | 5.8.3 / ≥ 22.11 |
| 实测设备 | HUAWEI TLR-AL00 真机,OpenHarmony 7.0.0.105(API 26) |
引用:
- ArkWeb Web 组件接口文档(
javaScriptProxy参数说明、permission权限配置):https://developer.huawei.com/consumer/cn/doc/harmonyos-references/arkts-basic-components-web-i - React Native for OpenHarmony 社区仓库(本文框架版本来源):https://atomgit.com/CPF-RN/ohos_react_native
- OWASP Mobile Application Security,WebView 与原生交互相关测试项:https://mas.owasp.org/
- 本文全部仓库代码:https://atomgit.com/dijun_520/WebViewSecurityLab
更多推荐





所有评论(0)