App 内嵌 H5 的信任边界设计(ArkWeb/HarmonyOS 实战)

0 阅读10分钟

引子:几个反复出现的提问

社区里关于原生与 H5 交互的提问,翻来覆去大致是这几类:

  • 「H5 里的请求怎么自动带上 App 的登录态,总不能让用户再登一次?」

  • 「H5 点个按钮要拉起原生页面,token 直接拼在 URL 后面传可以吗?」

  • 「原生退出登录了,H5 怎么立刻跟着失效?」

  • 「H5 支付回调要拉起收银台,订单号能不能由前端传?」

  • 「第三方活动页里放了个按钮,能不能复用同一个 WebView 容器?」

  • 「切换账号后,之前预加载的 H5 为什么还在显示上一个人的数据?」

  • 「埋点要带用户 ID,是直接把 token 给前端,还是另给一个 ID?」

讨论通常从「token 放哪儿」开始,也停在那儿。于是同一类缺陷换个位置又冒出来:这次是 URL 里的 token,下次是退出后没清干净的 Cookie,再下次是 H5 传来的一个路由名被直接塞进页面栈。

七个问题指向同一件事:原生和 H5 之间,信任到哪里为止,状态由谁做主。下面把提问拆成三类,给出一张风险矩阵和一份评审清单,再对照一套典型实现,把十个常见场景过一遍。

一、把高频问法拆成三类命题

1.1 三个命题,难度完全不同

身份贯通和失效传播其实是同一件事的两端。一个凭据方案好不好,主要看回收能力,而不是传递能力。

任何一次原生与 H5 的交互都能归进其中一类或几类,组合方式决定了风险落在哪。

1.2 四个信任域

很多人把「原生到 H5」想成一根管道,实际上中间隔了两次界:

原生进程  ──①──>  Web 内核(Cookie 库 / 网络栈)  ──②──>  页面脚本  ──③──>  服务端
  可信              半可信(系统管理)                 不可信            可信

两个常见误判都来自忽略中间那道界:

  • 以为「Cookie 注入到内核」就安全了。脚本域能不能读,取决于有没有 HttpOnly,与注入方式无关。

  • 以为「桥只在我打开的页面里生效」。桥对象注册在整个 WebView 实例上,实例里加载的任何页面都能调到同一个桥对象,导航到站外之后也一样,除非用 permission 白名单收敛(见 4.1 ④)。

1.3 四条通道,一张风险矩阵

跨域传递只有四条路。把它们按「谁能看见 / 是否落盘 / 脚本可读 / 能否撤销」摊开,选型就有了尺子:

选型时该看最后两列。URL 和 HttpOnly Cookie 的差距不在「能不能被看见」,而在能不能收回。JSBridge 常被当成更安全的替代,但它的可撤销性比 Cookie 更差:返回值一旦进了脚本域,原生就管不着那些副本了。

1.4 三条原则

矩阵里的约束可以收成三条:

  1. 凭据默认不下发到脚本域。能只放在内核就不放脚本,能完全不传递最好。

  2. 能力按意图授权,不按对象或路由授权。H5 的任何入参都不作为可信输入。

  3. 状态只有一个源,其余都是副本。失效必须扇出到每一个副本,并且副本要能自证过期。

第三条展开说:副本是必然存在的,Cookie 一份、H5 的 localStorage 可能又存一份、内存缓存一份、服务端会话一份。所以「立刻失效」不是删掉一份就完事,要三层联动:服务端作废让残留副本即使还在也无效,本地清理让后续请求不再携带,通知页面自清让界面立即对上。这是纵深防御,不是重复劳动。


二、身份贯通:凭据怎么给,更要怎么收

2.1 一个流传很广的说法

社区里有一种说法:

至于 token 是拼在 URL 后面还是放在 cookie 都一样的。对于不怀好心的人来说都是一样的透明。

这句话只对了一半,错的那一半很关键。

对的部分:如果 Cookie 没加 HttpOnly,页面里任何一段 JS(包括被 XSS 注入的)都能用 document.cookie 读走。这种情况下,Cookie 和 URL 在「可被脚本读取」上确实等价。

错的部分:它漏掉了不依赖脚本的泄漏路径。

Referer 和 access log 这两条是自动发生的:Referer 会把查询串带给页面里的每一个第三方资源(统计、CDN、字体),access log 里的 URL 会长期躺在日志平台上。跟攻击者有没有心无关,是设计本身在往外送数据。

准确结论:URL 传凭据会把凭据交给所有能看日志、看历史、看 Referer 的系统;HttpOnly Cookie 只暴露给服务端和网络链路。两者不是一回事。

2.2 五种模式,以及怎么选

按暴露面从小到大,实际有五档:

打个比方:

A 把钥匙贴在门上,B 把钥匙交给客人保管,C 把钥匙放进只有门能开的锁孔,D 只给一张一次性的临时门禁卡,E 干脆不给钥匙,客人要进门时由主人替他刷。

选型不用背表,按两个问题走即可:

H5 与 App 后端是否同域(或可控的父子域)?
├─ 否 → E(请求代理)或 D(一次性票据)
└─ 是 → 凭据是否允许落到任何持久化介质?
        ├─ 否 → D
        └─ 是 → C,且必须 HttpOnly + Secure + 正确的 SameSite

A 在任何情况下都不该用于凭据;它可以传非敏感的业务参数(如 skuId、活动 ID)。B 适合传非敏感、可公开的用户态(昵称、主题、语言),不适合传 token。

2.3 方案 C 的两个隐藏前提

域关系可控是第一个前提。Cookie 能自动携带,靠的是同源策略。H5 与 Cookie 的 domain 必须匹配;如果 H5 与 App 后端不在同一域名下,服务端要配合做跨域 Cookie 写入,复杂度会迅速逼近方案 E。这时候请求拦截代理反而更省事:原生代发请求并附加鉴权头,不受同源策略约束。

请求代理值得单独说:ArkTS 侧用 onInterceptRequest 事件拦截 H5 数据请求,Native 侧对应 ArkWeb_WebSchemeHandler 系列 C API,原生用 @kit.NetworkKit 代发并附加 Authorization。token 全程只在原生侧,H5 完全无感知。

  • H5 里没有凭据可窃取,泄漏路径直接消失

  • token 刷新、过期重试由原生统一管理,从根上解决「过期不同步」

  • 不受同源策略约束

代价是所有 H5 请求都要过原生一层,要处理请求体解析、流式响应、文件下载等边界。适合「安全优先且请求形态可控」的场景,不适合有大量媒体流或第三方 SDK 自由发请求的页面。

第二个前提是失效联动,清的地方也要对,见 3.2。

2.4 把「生命周期」写成一张表

「URL 泄露」和「过期不同步」常被当成两件事,其实都在问同一个问题:凭据传出去之后,怎么保证随时能作废。

四条里失效最容易跳过,后果也最重。前三条做不好,用户大概只是觉得有点不安全;失效做不好,就是别人已经拿我账号下单了。


三、能力开放与失效传播:同一枚硬币的两面

这两件事常被分开讨论,但前提是一样的:原生握着状态和权限,H5 只是副本和调用方。这个前提一破,两边都会出问题。

3.1 「拉起一个原生页」不是跳转需求,是一次能力授权

「H5 点个按钮调起原生页面」这类需求,最省事的实现是开放一个 goToAppPage(pageUrl)。这也是不少线上实现的做法。问题在于,它把「能跳哪些页」的决定权交给了 H5。

按意图授权,一次 H5 → 原生的调用应该过四层:

能力本身也要分级,不同级别要求不同:

两种写法对比:

// 反例:决定权在 H5 手里
goToAppPage(pageUrl: string, params?: ESObject) {
  RouterManager.push({ builderName: pageUrl, param: params });
}

// 正例:决定权在原生手里
openMyOrders(): void {
  RouterManager.push({
    routerName: RouterConstants.ENTRY_HAP,
    builderName: 'mall:StoreMyOrderPage',   // 常量,不接受 H5 入参
  });
}

openProductDetail(skuId: string): void {
  if (!/^[A-Za-z0-9_-]{1,32}$/.test(skuId)) {
    return;   // 参数 schema 校验,静默拒绝
  }
  RouterManager.push({
    routerName: RouterConstants.ENTRY_HAP,
    builderName: 'mall:MallStoreDetailsPage',
    param: { skuId: skuId },
  });
}

第二种写法还顺带解决了一个隐性问题:H5 不需要知道原生路由表长什么样,路由重构不会打断线上 H5。

3.2 失效传播:三层联动,顺序不能反

「清 Cookie + runJavaScript 通知」方向对,但少了一层,也没说顺序。完整链路:

① 调服务端登出接口  →  会话立即作废(安全层,根本保障)
② 清 WebView Cookie →  后续请求不再携带(本地层)
③ runJavaScript 通知 H5 →  立即跳未登录页(体验层)

先清本地再调服务端,中间有一个窗口期凭据仍然有效。凭据如果已经被复制出去(比如通过 Referer 泄漏),只有服务端作废能真正断掉。

多数实现只覆盖「用户主动退出」,还有三处必须补齐:

第一处是被动踢下线,走同一套。token 在别处登录被挤下线、服务端主动作废时,用户根本没点退出按钮。只监听退出按钮,H5 会一直保持「看起来已登录」。工程里通常已有踢下线事件(例如 APP_LOGIN_KICK_OUT 一类广播),缺的是把它接到 WebView。

第二处是 H5 侧的本地缓存,要一起清。

window.onNativeLogout = function (reason) {
  localStorage.clear();
  sessionStorage.clear();
  if (window.caches) {
    caches.keys().then(keys => keys.forEach(k => caches.delete(k)));
  }
  location.replace('/mall/login-required?reason=' + reason);
};

很多 H5 会把 token 或用户信息存进 localStorage。只清 Cookie 不通知页面,会出现「Cookie 没了但页面还显示已登录、点按钮报 401」的割裂状态。

第三处是幂等与竞态。主动退出、被动踢下线、网络层 401 三个入口可能在几秒内同时触发,扇出动作必须幂等。H5 如果已经带着旧 Cookie 把请求发出去了,本地清理来不及,服务端作废是唯一兜底。第 ① 步不能省。

3.3 两个容易被当成「边缘情况」的主流程

多 WebView 实例。clearAllCookiesSync() 是全局的。App 里若有活动页、客服页、支付中间页各自依赖不同域的 Cookie,一刀清空会把它们一起登出,而且现象分散、很难归因。会话 Cookie + clearSessionCookieSync() 是更精准的选择。要注意的是,ArkTS 侧没有按域、按名删除 Cookie 的接口,清理粒度只有「会话」和「全部」两档,所以域的规划要在设计期做:登录态统一用会话 Cookie,让会话级清理足够精准;确需强隔离的独立业务(如第三方落地页)可以用隐私模式 WebView,incognito 模式的 Cookie 只存内存、与正常模式隔离、组件销毁即清,但要接受不落盘、页面重开需重新注入的代价。

切换账号不是登出,是「换身份」。它要求「清旧态 + 注新态」原子完成,中间不能让 H5 用旧 Cookie 向新账号发请求。两个可行做法:换号时销毁并重建 WebView(最简单可靠),或者用一次性票据让 H5 在新会话建立前完全无凭据可用。预加载的 WebView 尤其危险:它注入的是上一个账号的 Cookie,复用前必须校验账号一致性,否则就是串号。


四、实现细节与 API 校准

框架要落地还得过 API 这一关。网上流传的示例里有几处会让人照着写、跑起来了、但没生效,下面逐条校准,再补几条同样容易踩的细节。代码示例只说明调用时机和参数结构,不是可直接编译的完整单元。

4.1 四处常见的 API 误用

① 注入时机:webview.once('webInited') 不是最稳的写法。

// 官方 FAQ 给出的写法
webview.once('webInited', () => {
  webview.WebCookieManager.configCookieSync(/* ... */)
})

这套写法对「进程内第一个 WebView」成立(见官方 FAQ:登录信息的 cookie 应该在什么时机注入),但有两个坑。其一,once() 是全局一次性事件:内核已完成初始化后,第二个及之后打开的 WebView 再执行这段代码,回调不再触发,注入静默失效。这是接口说明写明的行为(When other Web components are loaded in the same application, once() is not triggered)。其二,WebCookieManager 是所有 Web 组件共享的全局单例,configCookieSync 调用本身就会触发内核初始化,并不依赖 webInited 事件。真正需要保证的是「注入发生在该实例的首次请求之前」,不是「事件已触发」。

更稳的做法是放进组件自己的 onControllerAttached,并且在 loadUrl 之前执行。对每一个 WebView 实例都成立,首个 HTML 请求就能带上登录态:

Web({ src: '', controller: this.controller })
  .onControllerAttached(() => {
    webview.WebCookieManager.configCookieSync(domain, cookieValue);
    this.controller.loadUrl(this.mallUrl);
  })

这也是放 setUrlTrustList 的合理位置:该接口要求 controller 已关联组件(否则报 17100001),与 Cookie 注入一样,都应在 onControllerAttached 里、loadUrl 之前完成。

② 刷盘:写入成功不等于已持久化,必要时显式 saveCookieAsync()。

多数示例没提刷盘,而这正是「注入成功但偶发失效」的成因。官方说明是 cookies 每 30 秒周期性保存到磁盘,configCookieSync 只写进内核内存。进程在这 30 秒内被杀(用户划掉、系统回收),Cookie 就丢了。要立刻持久化得显式刷盘:

webview.WebCookieManager.configCookieSync(domain, cookieValue);
await webview.WebCookieManager.saveCookieAsync();   // 自 API 9 起提供

版本对应关系容易记错,这里按 WebCookieManager 接口文档 校准一次:saveCookieAsync() 自 API 9 就有;同步版本 saveCookieSync() 反而是 API 15 才新增,且会阻塞调用线程直到落盘完成。API 9→11 之间被废弃的是另一批旧接口:setCookie / getCookie / deleteEntireCookie / deleteSessionCookie。网上「saveCookieSync 已更名」的说法是错的。

③ 退出时清 Cookie:方法名和清理范围都容易写错。

选哪个取决于 Cookie 类型:登录态写成会话 Cookie(不带 expires / Max-Age)用 clearSessionCookieSync(),精准且不影响其他域;写成持久 Cookie(带 expires / Max-Age)则会话清理方法清不掉。ArkTS 侧没有按域、按名删除单条 Cookie 的接口,只能退到 clearAllCookiesSync() 全量清理,代价是把其他业务的登录态一起带走。

④ permission 白名单:参数给对了,写法是空的。

const BRIDGE_PERMISSION: string = JSON.stringify({
  javascriptProxyPermission: {
    urlPermissionList: [
      { scheme: 'https', host: 'mall.example.com', port: '', path: '' }
    ],
    methodList: []      // ← 空数组
  }
});

methodList: [] 表示没有为任何方法单独配置权限。permission 分两层:urlPermissionList 是对象级(匹配则授权该对象的所有方法),methodList 是方法级(针对单个方法收紧)。空数组意味着方法级管控没生效,对象级仍然放行,但「只允许 getToken 从商城域调用」这类精细控制就没了。正确做法是给敏感方法单独配:

const BRIDGE_PERMISSION: string = JSON.stringify({
  javascriptProxyPermission: {
    urlPermissionList: [
      { scheme: 'https', host: 'mall.example.com', port: '', path: '' }
    ],
    methodList: [
      {
        methodName: 'getToken',
        urlPermissionList: [
          { scheme: 'https', host: 'mall.example.com', port: '', path: '' }
        ]
      }
    ]
  }
});

匹配规则三条:scheme 和 host 精确匹配、不能为空、不支持通配符(通配符是新版本 setUrlTrustList 才支持);port 留空表示不检查端口;path 是前缀匹配,留空表示不检查路径。还有一条容易漏的交集原则:方法 A 的最终白名单是对象级与方法级的交集,两侧必须同时配,只配一侧该方法就调不通。以上均出自官方 Jsb Permission Denied 问题定位。

4.2 八条实现细节

  1. SameSite 默认是 Lax,不是 None。跨站跳转还想携带 Cookie 要显式设 None; Secure,否则 Cookie 不会发送。这一条要跟 H5 确认跳转来源后再定。

  2. Cookie 每 30 秒自动落盘,崩溃会丢最近 30 秒。另外官方说明:在 PC / 二合一 / 平板上,即使是会话 Cookie 也不会被 saveCookieAsync() 写入磁盘。多设备形态要单独验证。

  3. configCookieSync 的「多条 Cookie」要分清是哪种多。官方两处说法看着矛盾:「Cookie 注入时机」FAQ 的示例把多个键值对塞进一次调用(configCookie('', 'a=b,c=d,e=f'),还是用逗号拼的);而接口说明和「多个 Cookie 如何进行批量设置」FAQ 都明确「接口不支持一次性批量设置,应多次调用」。原因是 value 必须遵循 Set-Cookie 响应头格式,key=value 后面跟的是分号分隔的属性列表(如 key=value; Max-Age=100; Secure)。所以 c=d、e=f 会被内核当成前一条 Cookie 的未知属性,逗号拼法更糟:逗号是合法的 Cookie 值字符,整串会被当成 a 一个 Cookie 的值,实际都只写入一个 Cookie。

    // 反例:看着像写了三个,实际只写入一个 configCookieSync(url, 'a=b;c=d;e=f'); // 正例:多个 Cookie 就多次调用 configCookieSync(url, 'a=b; Path=/; Secure'); configCookieSync(url, 'c=d; Path=/; Secure');

    附带规则:相同 host、path、名称的 Cookie 会被新值替换;已过期的 Cookie 不会被存储;带 Secure 属性时 url 必须是 https://,否则写入失败;要覆盖已有的 HttpOnly Cookie,value 里同样要带 HttpOnly 属性。最后这条是「Cookie 注入后 H5 还是未登录」最隐蔽的成因之一。

  4. methodList 和 asyncMethodList 不能混。methodList 放同步方法,返回值直接回传 H5;asyncMethodList 放返回 Promise 的方法,其 resolve 值才回传。把异步方法误登记到 methodList,H5 侧 await bridge.xxx() 的 .then / await 永远不触发,表现是「回调不触发」,极易被误判成「桥没注入」。排查时这两个方向要分开验证。

  5. 动态注册要记得 refresh()。用 registerJavaScriptProxy() 注册后必须调 refresh() 才生效,否则前端报「对象未定义」,容易误以为是方法名写错。

  6. 组件销毁时释放桥对象。调 deleteJavaScriptRegister(name),防内存泄漏。

  7. 测试用例必须覆盖「未登录态」。很多线上事故是「退出后 H5 还在用旧态」,而自测时从来都是登录状态,这条路径根本没跑过。加一条:登录 → 打开 H5 → 原生退出 → 返回 H5 → 断言处于未登录。

  8. 多端统一抽象一层。iOS WKWebView、Android WebView、HarmonyOS ArkWeb 的 Cookie 同步机制各不相同。抽一个 H5SessionBridge 接口,各端分别实现,业务侧只调接口,不然同一套逻辑要在三端各踩一遍坑。


五、把框架用到别处

下面把框架抽成一份可以直接贴进评审单的清单,再套到十个场景上。

5.1 新需求来了,先过这三步

① 归类:这次交互属于身份贯通 / 能力开放 / 失效传播 里的哪几类?
② 选通道:涉及凭据就查 1.3 的风险矩阵,重点看「可撤销」列;涉及调用就查 3.1 的四层校验
③ 过清单:把 5.2 的 14 条对着需求打勾,缺哪条补哪条

5.2 通用评审清单

可以直接复制进任何 hybrid 需求的评审单。三组各四条,共 12 项,再加 2 条跨组项,合计 14 项。

身份贯通

  • [ ] URL 里没有 token、cookie、sessionId、手机号、身份证号等敏感项(非敏感业务参数可以留)

  • [ ] 凭据若落到 Web 内核,带齐 HttpOnly + Secure + 明确的 SameSite;Secure 对应的 URL 是 https

  • [ ] 凭据没有通过 JSBridge 返回值下发到脚本域;确实需要下发时,是一次性票据而非长期 token

  • [ ] 域关系明确(同域 / 父子域 / 跨域),跨域场景已选定代理或票据方案

能力开放

  • [ ] 桥对象注册时配置了 permission,且 methodList 对敏感方法单独收紧

  • [ ] 加载域名有系统级白名单(setUrlTrustList 或等价手段),覆盖 location.href、302、iframe 导航

  • [ ] 暴露的是意图方法(openMyOrders()),不是通用路由 / builderName 透传

  • [ ] 所有 H5 入参做 schema 校验;能力按读 / 跳 / 写 / 敏感分级,敏感级不下放

失效传播

  • [ ] 退出、被动踢下线、网络层 401 三个入口都接到了同一套扇出逻辑,且动作幂等

  • [ ] 顺序是「服务端作废 → 清内核 Cookie → 通知页面」,没有反向

  • [ ] 通知 H5 时清了 localStorage / sessionStorage / Cache API,界面立即切未登录

  • [ ] 会话 Cookie 用会话级清理方法,没有误用全局 clearAllCookiesSync() 波及其他 WebView

再加两条跨组的,最容易在迭代中被破坏:

  • [ ] 切换账号、WebView 预加载复用前,校验了账号一致性

  • [ ] 「登录 → 打开 H5 → 退出 → 返回 H5」有用例覆盖

5.3 把清单套到十个同类场景

十行都是同一套清单在不同组合下的输出。1.4 的三条原则和 1.3 的最后两列才是判据。

5.4 这套框架什么时候不适用

有几种场景不适用:

  • 纯原生页面:没有 WebView,四条通道不存在,直接走原生鉴权即可。

  • 系统浏览器、小程序等不受自己控制的容器:域模型不同,无法注入 Cookie、也无法注册桥,只能走标准 OAuth / 深链回跳。

  • 请求形态高度不可控的页面(WebRTC、大范围流式下载、三方 SDK 自由发请求):请求代理方案 E 的维护成本会超过收益,应转向票据方案 D。

  • 完全自有、同域、同发布链路且已有严格 XSS 防护的 H5:框架仍适用,但能力分级可以适当放宽;放宽要写进评审记录,而不是默认省略。


六、对照典型实现:六个现状与三步改造

下面是一套混合应用里很常见的实现形态,具体标识和代码位置已略去,只保留影响安全判断的部分。用第五章的清单对照,六条都不通过。

6.1 URL 里直接拼 cookie 和手机号

页面加载 URL 的方法里,参数是这么拼的:

if (this.userInfoData?.userId) {
  let cleanedString = this.userInfoData.name?.replace(/\n/g, '') ?? ''
  const url =
    this.data?.url + (this.data?.url.includes('?') ? '&' : '?') + 'userId=' +
    this.userInfoData.userId +
      '&userName=' + encodeURIComponent(cleanedString ?? '') +
      '&userPhone=' +
    this.userInfoData.phone + '&cookie=' + AppStorage.get('cookie')
  this.viewModel.loadUrl(url, undefined, this.data?.needShowLoading)
}

cookie(完整登录态)和 userPhone(手机号明文)同时进了 URL。按 2.1 的泄漏表,这两项会同时进 access log 和 Referer。

6.2 JSBridge 注册方法,且能直接读到 cookie

桥对象的 methodList 里列了 多 个方法,getCookie 排在第一个。它的实现直接返回完整登录态:

let userInfo: User = {
  userId: ...,
  userPhone: ...,
  userName: encodeURIComponent(...),
  cookie: AppStorage.get('cookie') ?? '',
  deviceSN: getDeviceInfo().deviceSn,
}

cookie 是 HttpOnly 的反面:凭据直接进了脚本域。而且 javaScriptProxy 注册时没有传 permission 参数,默认不做来源校验。

6.3 完全没有使用 WebCookieManager

整套实现里没有用到 configCookieSync / WebCookieManager。H5 拿登录态全靠 URL 拼接和 JSBridge。

好处是迁移到方案 C 属于纯增量改造,不用先清理存量 Cookie 逻辑。

6.4 路由能力对 H5 完全敞开

路由方法的实现:

goToAppPage(pageUrl: string, params?: ESObject) {
  this.logH5Call('goToAppPage', JSON.stringify({ pageUrl, params }));
  RouterManager.push({
    routerName: RouterConstants.ENTRY_HAP,
    builderName: pageUrl,     // 直接透传 H5 入参,无白名单
    param: params,
  })
}

6.5 退出登录没有通知 H5 的链路

退出登录的 clearLoginInfo 只做了三件事:清内存、清部分缓存、发一个改皮肤的事件。没有任何「通知 WebView」的动作。

所以「原生退出后 H5 立刻失效」这一条没有实现。网络层其实已经会发 APP_LOGIN_KICK_OUT 一类的踢下线事件,缺的只是把它接到 WebView。

6.6 没有域名白名单

onLoadIntercept 只拦了微信支付(weixin://wap/pay)和支付宝(alipays://)两种 scheme,没有域名校验,也没有用 setUrlTrustList。桥对象注册在整个 WebView 实例上,WebView 一旦被导航到站外,那个页面同样能调到 commonWebObj.getCookie()。

6.7 三步改造,每步都能独立交付

第一步,URL 里摘掉敏感项,成本最低。把 6.1 里的 cookie 和 userPhone 从 URL 摘掉,改为 H5 按需通过桥方法取脱敏信息。改动极小,但直接消掉 Referer 和 access log 两条自动化泄漏路径。不需要服务端配合,也没有兼容风险。

第二步,Cookie 注入加失效联动。

// 1. 注入(onControllerAttached 里,loadUrl 之前)
webview.WebCookieManager.configCookieSync(
  mallDomain,
  `SESSION_ID=${sessionId}; Domain=${host}; Path=/; Secure; HttpOnly; SameSite=Lax`
);
await webview.WebCookieManager.saveCookieAsync();

// 2. 退出时清理(三个入口都要接)
async function onNativeLogout(reason: 'manual' | 'kicked' | 'expired') {
  // 服务端失效优先(由登录模块负责)
  webview.WebCookieManager.clearSessionCookieSync();
  await webview.WebCookieManager.saveCookieAsync();
  controller.runJavaScript(`window.onNativeLogout && window.onNativeLogout('${reason}')`);
}

要接的三个入口:

第三步,路由与桥方法白名单,可以独立推进。

// 过渡:先给 goToAppPage 加白名单,堵住任意路由跳转
private static readonly H5_ALLOWED_ROUTES: string[] = [
  'mall:StoreMyOrderPage',
  'mall:MallStoreDetailsPage',
  'service:ChargeOrderListPage',
];

goToAppPage(pageUrl: string, params?: ESObject) {
  if (!H5_ALLOWED_ROUTES.includes(pageUrl)) {
    webViewLogger.warn(`H5 尝试跳转未授权路由: ${pageUrl}`);
    return;   // 静默拒绝,不给攻击者反馈
  }
  RouterManager.push({ routerName: RouterConstants.ENTRY_HAP, builderName: pageUrl, param: params });
}
// 目标:逐步废弃 goToAppPage,换成 3.1 的意图方法

Web 组件侧的配置:

Web({ src: '', controller: this.controller })
  // 域名信任列表:比 onLoadIntercept 手工判断更彻底
  .onControllerAttached(() => {
    const trustList = '{"UrlPermissionList":[{"scheme":"https","host":"mall.example.com"}]}';
    this.controller.setUrlTrustList(trustList);
  })
  // 桥方法来源白名单
  .javaScriptProxy({
    object: this.viewModel.commonWebObj,
    name: this.viewModel.name,
    methodList: this.viewModel.methodList,
    controller: this.viewModel.controller,
    permission: BRIDGE_PERMISSION,   // 见 4.1 ④ 的正确结构
  })

setUrlTrustList 的优势是系统级全量校验:所有导航(包括 H5 内部的 location.href 跳转、302 重定向)都会被拦,不依赖你在 onLoadIntercept 里是否漏写了某个分支。注意它要求 controller 已关联组件(否则报 17100001),所以同样放在 onControllerAttached 里调用;通配符支持(*.example.com)要等三参重载(allowOpaqueOrigin + supportWildcard,API 24 起)。

三步做完,清单里三组共 12 项都能打勾。剩下 2 条跨组项要单独补:切换账号 / 预加载复用前的账号一致性校验,以及「登录 → 打开 H5 → 退出 → 返回 H5」的用例覆盖。顺序按收益和成本排,不按依赖,任意一步都能先上。


七、结语:管的是生命周期,不是通道

「URL 泄露」和「token 过期不同步」看着是传输问题,落到实现上都是生命周期问题:凭据传出去之后,怎么保证随时能作废。

那句「放 URL 和放 Cookie 都一样」,如果只针对「都能被 JS 读走」,说的是对的。但 HttpOnly Cookie 消掉了四条不依赖脚本的泄漏路径,这个差别在安全评审里足以改变结论。

混合应用里的事故,多数不是某个 API 用错,而是四个域之间的信任关系没定清楚。凭据落到脚本域、通用路由下放给前端、退出时只清了一处副本,发生在三个完全不同的需求里,根子是一样的。

  1. 凭据默认不下发到脚本域,判断方案看能不能收回;

  2. 能力按意图授权,不按路由授权;

  3. 状态只有一个源,失效要扇出到每一个副本,服务端作废是最后兜底。

5.2 那 14 条清单可以直接贴进评审单。下次再遇到「H5 支付回调怎么接」「切换账号后页面为什么串了」,至少不用从「token 放哪儿」重新讲一遍。


附录 A:API 名称与版本速查

技术文章容易在这里翻车,单独列一张表(版本信息已按 SDK .d.ts 核对):

不同 API 版本的方法名与签名存在差异,尤其 configCookieSync 的参数个数和 clearSessionCookieSync 的可用版本。落地前请以目标 SDK 的 .d.ts 为准,不要只依据网络文章。

附录 B:官方文档索引