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,翻译过来是六句话:

  1. JS-01:桥不提供通用调用入口,方法必须逐个注册;
  2. JS-02:每次调用校验页面来源,来源从容器读,不信 H5 自报;
  3. JS-03:参数按 schema 校验,未声明的参数直接拒绝;
  4. JS-04:返回值脱敏,脱敏在原生侧做,不指望 H5;
  5. JS-05:按方法维度限流,敏感能力用户二次确认;
  6. 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 / 70 / 7−7应用内配置审计器(src/security/webviewConfig.ts)+源码对照
2JSBridge 越权成功6 条探针中任一条拿到本不该给 H5 的数据或能力,记 1 次4 / 60 / 6−4页面内 6 条 JS 探针 + javaScriptProxy,结果 JSON 回传原生
3敏感字段明文外泄getDeviceInfo 返回的 5 个字段中未打码的个数5 / 50 / 5−5探针返回值逐字段正则审计(是否含 *)
4外链导航拦截5 次真实 location.href 导航中被取消的 BLOCK 次数0 / 54 / 5+4onLoadIntercept 回调 + hilog(grep BENCH_NAV)
5首屏加载耗时onPageBegin→onPageEnd,5 轮取中位数25 ms7 ms−18 msArkTS Date.now() 在容器回调里打点
6进程 PSShidumper --mem <pid> 采样125.9 MB127.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.ts15 项容器开关、33 条红线的命中与评分
场景 01 业务内嵌 H5webviewConfig.ts文件域/混合内容/多窗口等能力边界
场景 02 JSBridgebridge.ts + gateway.ts白名单、参数、脱敏、频控、确认、审计链
场景 03 本地资源与离线包offlinePackage.tsfile 域隔离、解压穿越、热更验签
场景 04 外部链接urlGuard.ts精确域名匹配、scheme/端口白名单、系统浏览器兜底
场景 05 Cookie/存储cookiePolicy.tsHttpOnly/Secure/SameSite、退出清理
场景 06 调试开关webviewConfig.ts(debugEnabled)远程调试旁路的开关策略
场景 07 证书校验certificate.tsSSL 错误处理、证书锁定
场景 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. 落地顺序按性价比排,别一次到位。

  1. 先做注册表 + methodList 静态同源(半天工作量,直接消灭通用入口和未注册方法);
  2. 加参数 schema 校验与"拒绝未声明参数"(一天,含存量方法补规格);
  3. 加返回值递归脱敏(一天,先覆盖 device/pay/user 三类高敏方法);
  4. 加来源校验与审计日志(半天,注意内部页 origin 的处理);
  5. 最后做频控与二次确认令牌(两天,需要原生弹窗配合)。

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 Studio26.0.0.851(Build DS-261.23567.138.36.2600851)
compileSdkVersion / compatibleSdkVersion26.0.0 / 5.0.5(17)
React Native0.84.1(react 19.2.3)
@react-native-oh/react-native-harmony0.84.1
TypeScript / Node5.8.3 / ≥ 22.11
实测设备HUAWEI TLR-AL00 真机,OpenHarmony 7.0.0.105(API 26)

引用:

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐