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

<h2>引子:几个反复出现的提问</h2>

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

<ul>
<li>
<p>「H5 里的请求怎么自动带上 App 的登录态,总不能让用户再登一次?」</p>
</li>
<li>
<p>「H5 点个按钮要拉起原生页面,token 直接拼在 URL 后面传可以吗?」</p>
</li>
<li>
<p>「原生退出登录了,H5 怎么立刻跟着失效?」</p>
</li>
<li>
<p>「H5 支付回调要拉起收银台,订单号能不能由前端传?」</p>
</li>
<li>
<p>「第三方活动页里放了个按钮,能不能复用同一个 WebView 容器?」</p>
</li>
<li>
<p>「切换账号后,之前预加载的 H5 为什么还在显示上一个人的数据?」</p>
</li>
<li>
<p>「埋点要带用户 ID,是直接把 token 给前端,还是另给一个 ID?」</p>
</li>
</ul>

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

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

<h2>一、把高频问法拆成三类命题</h2>

<h3>1.1 三个命题,难度完全不同</h3>

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

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

<h3>1.2 四个信任域</h3>

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

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

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

<ul>
<li>
<p>以为「Cookie 注入到内核」就安全了。脚本域能不能读,取决于有没有 <code>HttpOnly</code>,与注入方式无关。</p>
</li>
<li>
<p>以为「桥只在我打开的页面里生效」。桥对象注册在整个 WebView 实例上,实例里加载的任何页面都能调到同一个桥对象,导航到站外之后也一样,除非用 <code>permission</code> 白名单收敛(见 4.1 ④)。</p>
</li>
</ul>

<h3>1.3 四条通道,一张风险矩阵</h3>

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

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

<h3>1.4 三条原则</h3>

<p>矩阵里的约束可以收成三条:</p>

<ol>
<li>
<p>凭据默认不下发到脚本域。能只放在内核就不放脚本,能完全不传递最好。</p>
</li>
<li>
<p>能力按意图授权,不按对象或路由授权。H5 的任何入参都不作为可信输入。</p>
</li>
<li>
<p>状态只有一个源,其余都是副本。失效必须扇出到每一个副本,并且副本要能自证过期。</p>
</li>
</ol>

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



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

<h3>2.1 一个流传很广的说法</h3>

<p>社区里有一种说法:</p>

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

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

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

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

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

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

<h3>2.2 五种模式,以及怎么选</h3>

<p>按暴露面从小到大,实际有五档:</p>

<p>打个比方:</p>

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

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

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

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

<h3>2.3 方案 C 的两个隐藏前提</h3>

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

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

<ul>
<li>
<p>H5 里没有凭据可窃取,泄漏路径直接消失</p>
</li>
<li>
<p>token 刷新、过期重试由原生统一管理,从根上解决「过期不同步」</p>
</li>
<li>
<p>不受同源策略约束</p>
</li>
</ul>

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

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

<h3>2.4 把「生命周期」写成一张表</h3>

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

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



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

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

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

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

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

<p>能力本身也要分级,不同级别要求不同:</p>

<p>两种写法对比:</p>

<pre><code>// 反例:决定权在 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 },
});
}
</code></pre>

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

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

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

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

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

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

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

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

<pre><code>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);
};
</code></pre>

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

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

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

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

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



<h2>四、实现细节与 API 校准</h2>

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

<h3>4.1 四处常见的 API 误用</h3>

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

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

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

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

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

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

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

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

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

<p>版本对应关系容易记错,这里按 <a href="https://developer.huawei.com/consumer/cn/doc/harmonyos-references/arkts-apis-webview-webcookiemanager" title="WebCookieManager 接口文档">WebCookieManager 接口文档</a> 校准一次:<code>saveCookieAsync()</code> 自 API 9 就有;同步版本 <code>saveCookieSync()</code> 反而是 API 15 才新增,且会阻塞调用线程直到落盘完成。API 9→11 之间被废弃的是另一批旧接口:<code>setCookie</code> / <code>getCookie</code> / <code>deleteEntireCookie</code> / <code>deleteSessionCookie</code>。网上「<code>saveCookieSync</code> 已更名」的说法是错的。</p>

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

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

<p>④ <code>permission</code> 白名单:参数给对了,写法是空的。</p>

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

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

<pre><code>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: '' }
]
}
]
}
});
</code></pre>

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

<h3>4.2 八条实现细节</h3>

<ol>
<li>
<p><code>SameSite</code> 默认是 Lax,不是 None。跨站跳转还想携带 Cookie 要显式设 <code>None; Secure</code>,否则 Cookie 不会发送。这一条要跟 H5 确认跳转来源后再定。</p>
</li>
<li>
<p>Cookie 每 30 秒自动落盘,崩溃会丢最近 30 秒。另外官方说明:在 PC / 二合一 / 平板上,即使是会话 Cookie 也不会被 <code>saveCookieAsync()</code> 写入磁盘。多设备形态要单独验证。</p>
</li>
<li>
<p><code>configCookieSync</code> 的「多条 Cookie」要分清是哪种多。官方两处说法看着矛盾:「Cookie 注入时机」FAQ 的示例把多个键值对塞进一次调用(<code>configCookie('', 'a=b,c=d,e=f')</code>,还是用逗号拼的);而接口说明和<a href="https://developer.huawei.com/consumer/cn/doc/faqs/faqs-arkweb-25" title="「多个 Cookie 如何进行批量设置」FAQ">「多个 Cookie 如何进行批量设置」FAQ</a> 都明确「接口不支持一次性批量设置,应多次调用」。原因是 <code>value</code> 必须遵循 <code>Set-Cookie</code> 响应头格式,<code>key=value</code> 后面跟的是分号分隔的属性列表(如 <code>key=value; Max-Age=100; Secure</code>)。所以 <code>c=d</code>、<code>e=f</code> 会被内核当成前一条 Cookie 的未知属性,逗号拼法更糟:逗号是合法的 Cookie 值字符,整串会被当成 <code>a</code> 一个 Cookie 的值,实际都只写入一个 Cookie。</p>

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

<p>附带规则:相同 host、path、名称的 Cookie 会被新值替换;已过期的 Cookie 不会被存储;带 <code>Secure</code> 属性时 url 必须是 <code>https://</code>,否则写入失败;要覆盖已有的 <code>HttpOnly</code> Cookie,<code>value</code> 里同样要带 <code>HttpOnly</code> 属性。最后这条是「Cookie 注入后 H5 还是未登录」最隐蔽的成因之一。</p>
</li>
<li>
<p><code>methodList</code> 和 <code>asyncMethodList</code> 不能混。<code>methodList</code> 放同步方法,返回值直接回传 H5;<code>asyncMethodList</code> 放返回 Promise 的方法,其 <code>resolve</code> 值才回传。把异步方法误登记到 <code>methodList</code>,H5 侧 <code>await bridge.xxx()</code> 的 <code>.then</code> / <code>await</code> 永远不触发,表现是「回调不触发」,极易被误判成「桥没注入」。排查时这两个方向要分开验证。</p>
</li>
<li>
<p>动态注册要记得 <code>refresh()</code>。用 <code>registerJavaScriptProxy()</code> 注册后必须调 <code>refresh()</code> 才生效,否则前端报「对象未定义」,容易误以为是方法名写错。</p>
</li>
<li>
<p>组件销毁时释放桥对象。调 <code>deleteJavaScriptRegister(name)</code>,防内存泄漏。</p>
</li>
<li>
<p>测试用例必须覆盖「未登录态」。很多线上事故是「退出后 H5 还在用旧态」,而自测时从来都是登录状态,这条路径根本没跑过。加一条:登录 → 打开 H5 → 原生退出 → 返回 H5 → 断言处于未登录。</p>
</li>
<li>
<p>多端统一抽象一层。iOS <code>WKWebView</code>、Android <code>WebView</code>、HarmonyOS ArkWeb 的 Cookie 同步机制各不相同。抽一个 <code>H5SessionBridge</code> 接口,各端分别实现,业务侧只调接口,不然同一套逻辑要在三端各踩一遍坑。</p>
</li>

</ol>



<h2>五、把框架用到别处</h2>

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

<h3>5.1 新需求来了,先过这三步</h3>

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

<h3>5.2 通用评审清单</h3>

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

<p><strong>身份贯通</strong></p>

<ul>
<li>
<p>[ ] URL 里没有 token、cookie、sessionId、手机号、身份证号等敏感项(非敏感业务参数可以留)</p>
</li>
<li>
<p>[ ] 凭据若落到 Web 内核,带齐 <code>HttpOnly</code> + <code>Secure</code> + 明确的 <code>SameSite</code>;<code>Secure</code> 对应的 URL 是 https</p>
</li>
<li>
<p>[ ] 凭据没有通过 JSBridge 返回值下发到脚本域;确实需要下发时,是一次性票据而非长期 token</p>
</li>
<li>
<p>[ ] 域关系明确(同域 / 父子域 / 跨域),跨域场景已选定代理或票据方案</p>
</li>
</ul>

<p><strong>能力开放</strong></p>

<ul>
<li>
<p>[ ] 桥对象注册时配置了 <code>permission</code>,且 <code>methodList</code> 对敏感方法单独收紧</p>
</li>
<li>
<p>[ ] 加载域名有系统级白名单(<code>setUrlTrustList</code> 或等价手段),覆盖 <code>location.href</code>、302、iframe 导航</p>
</li>
<li>
<p>[ ] 暴露的是意图方法(<code>openMyOrders()</code>),不是通用路由 / <code>builderName</code> 透传</p>
</li>
<li>
<p>[ ] 所有 H5 入参做 schema 校验;能力按读 / 跳 / 写 / 敏感分级,敏感级不下放</p>
</li>
</ul>

<p><strong>失效传播</strong></p>

<ul>
<li>
<p>[ ] 退出、被动踢下线、网络层 401 三个入口都接到了同一套扇出逻辑,且动作幂等</p>
</li>
<li>
<p>[ ] 顺序是「服务端作废 → 清内核 Cookie → 通知页面」,没有反向</p>
</li>
<li>
<p>[ ] 通知 H5 时清了 <code>localStorage</code> / <code>sessionStorage</code> / Cache API,界面立即切未登录</p>
</li>
<li>
<p>[ ] 会话 Cookie 用会话级清理方法,没有误用全局 <code>clearAllCookiesSync()</code> 波及其他 WebView</p>
</li>
</ul>

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

<ul>
<li>
<p>[ ] 切换账号、WebView 预加载复用前,校验了账号一致性</p>
</li>
<li>
<p>[ ] 「登录 → 打开 H5 → 退出 → 返回 H5」有用例覆盖</p>
</li>
</ul>

<h3>5.3 把清单套到十个同类场景</h3>

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

<h3>5.4 这套框架什么时候不适用</h3>

<p>有几种场景不适用:</p>

<ul>
<li>
<p>纯原生页面:没有 WebView,四条通道不存在,直接走原生鉴权即可。</p>
</li>
<li>
<p>系统浏览器、小程序等不受自己控制的容器:域模型不同,无法注入 Cookie、也无法注册桥,只能走标准 OAuth / 深链回跳。</p>
</li>
<li>
<p>请求形态高度不可控的页面(WebRTC、大范围流式下载、三方 SDK 自由发请求):请求代理方案 E 的维护成本会超过收益,应转向票据方案 D。</p>
</li>
<li>
<p>完全自有、同域、同发布链路且已有严格 XSS 防护的 H5:框架仍适用,但能力分级可以适当放宽;放宽要写进评审记录,而不是默认省略。</p>
</li>
</ul>



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

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

<h3>6.1 URL 里直接拼 cookie 和手机号</h3>

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

<pre><code>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)
}
</code></pre>

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

<h3>6.2 JSBridge 注册方法,且能直接读到 cookie</h3>

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

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

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

<h3>6.3 完全没有使用 WebCookieManager</h3>

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

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

<h3>6.4 路由能力对 H5 完全敞开</h3>

<p>路由方法的实现:</p>

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

<h3>6.5 退出登录没有通知 H5 的链路</h3>

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

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

<h3>6.6 没有域名白名单</h3>

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

<h3>6.7 三步改造,每步都能独立交付</h3>

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

<p>第二步,Cookie 注入加失效联动。</p>

<pre><code>// 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 &amp;&amp; window.onNativeLogout('${reason}'));
}
</code></pre>

<p>要接的三个入口:</p>

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

<pre><code>// 过渡:先给 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 的意图方法
</code></pre>

<p>Web 组件侧的配置:</p>

<pre><code>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 ④ 的正确结构
})
</code></pre>

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

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



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

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

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

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

<ol>
<li>
<p>凭据默认不下发到脚本域,判断方案看能不能收回;</p>
</li>
<li>
<p>能力按意图授权,不按路由授权;</p>
</li>
<li>
<p>状态只有一个源,失效要扇出到每一个副本,服务端作废是最后兜底。</p>
</li>
</ol>

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



<h2>附录 A:API 名称与版本速查</h2>

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

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

<h2>附录 B:官方文档索引</h2>

<ul>
<li>
<p><a href="https://developer.huawei.com/consumer/cn/doc/harmonyos-references/arkts-apis-webview-webcookiemanager" title="WebCookieManager(configCookieSync / saveCookieAsync / clearSessionCookieSync / clearAllCookiesSync)">WebCookieManager(configCookieSync / saveCookieAsync / clearSessionCookieSync / clearAllCookiesSync)</a></p>
</li>
<li>
<p><a href="https://developer.huawei.com/consumer/cn/doc/harmonyos-references/arkts-apis-webview-webviewcontroller" title="WebviewController(setUrlTrustList / registerJavaScriptProxy / deleteJavaScriptRegister / runJavaScript)">WebviewController(setUrlTrustList / registerJavaScriptProxy / deleteJavaScriptRegister / runJavaScript)</a></p>
</li>
<li>
<p><a href="https://developer.huawei.com/consumer/cn/doc/harmonyos-references/arkts-apis-webview-f" title="webview Functions(once / webInited 事件说明)">webview Functions(once / webInited 事件说明)</a></p>
</li>
<li>
<p><a href="https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/web-cookie-and-data-storage-mgmt" title="管理 Cookie 及数据存储(SameSite 默认值 / 30 秒落盘 / setLazyInitializeWebEngine)">管理 Cookie 及数据存储(SameSite 默认值 / 30 秒落盘 / setLazyInitializeWebEngine)</a></p>
</li>
<li>
<p><a href="https://developer.huawei.com/consumer/cn/doc/harmonyos-references/arkts-basic-components-web-web" title="Web 组件(javaScriptProxy 属性与 JavaScriptProxy 类型定义)">Web 组件(javaScriptProxy 属性与 JavaScriptProxy 类型定义)</a></p>
</li>
<li>
<p><a href="https://developer.huawei.com/consumer/cn/doc/faqs/faqs-arkweb-26" title="FAQ:登录信息的 cookie 应该在什么时机注入">FAQ:登录信息的 cookie 应该在什么时机注入</a></p>
</li>
<li>
<p><a href="https://developer.huawei.com/consumer/cn/doc/faqs/faqs-arkweb-25" title="FAQ:多个 Cookie 如何进行批量设置">FAQ:多个 Cookie 如何进行批量设置</a></p>
</li>
<li>
<p><a href="https://developer.huawei.com/consumer/cn/doc/faqs/faqs-arkweb-20" title="FAQ:javaScriptProxy 和 registerJavaScriptProxy 有什么区别">FAQ:javaScriptProxy 和 registerJavaScriptProxy 有什么区别</a></p>
</li>
<li>
<p><a href="https://developer.huawei.com/consumer/cn/doc/faqs/faqs-arkweb-160" title="FAQ:Jsb Permission Denied 报错如何解决(permission 结构与交集原则)">FAQ:Jsb Permission Denied 报错如何解决(permission 结构与交集原则)</a></p>
</li>
</ul>

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容