接第三方活动页时,我最怕的不是页面打不开,而是它“能打开,也能调用宿主”,却把调用边界留给了 H5 自己约定。开发阶段大家都在可信测试环境里,window.host.xxx() 看起来十分顺畅。活动换了一版脚本、页面被重定向,或者用户在回调未结束时退出页面后,隐患才会逐渐浮出来:重复统计、过期回调修改首页状态、陌生方法拿到不该有的能力。

于是这次做了一个小型验证工程 CampaignBridge:接入“夏日探索计划”H5 页面,把调用入口收拢成两项受控能力 trackEvent 和 requestClose,把故意构造的 openSystemSettings 当作未授权操作拦截,并让所有回调挂靠到一个可失效的会话 cb_20261009_02。这并不是用几行代码就能“保证绝对安全”,而是把混乱的协议缩到一个能测试、能审计、能释放的工程边界。

一、故障不是发生在加载阶段,而是在页面离开之后

上一版活动页曾出现过这样的情况:用户点击活动后立即返回首页,随后隔了半秒,首页某个统计状态突然改变。排查才知道,活动页发起的异步任务仍然返回结果;由于回调保存着旧页面的引用,它照常写入已离开的 UI。另一次问题更直接:第三方前端给宿主传了一个未约定的 openSystemSettings 方法名,协议分发器没有白名单,结果开发样例意外执行了一个本不应该开放的分支。

这两种问题表面无关。前者偏生命周期,后者偏权限,却都来自同一个设计误区:把“消息能传过来”误认为“消息在任何时间都有处理资格”。宿主真正需要识别四个维度:谁发来、要做什么、参数是否合规、当前会话是否仍然有效。缺少任意一个维度,接口都可能被滥用或者在错误的时间生效。

我把目标划成很可操作的四条规则。H5 页面只能调用允许的方法;调用参数要有结构和长度约束;每个回调绑定创建时的会话 ID;页面结束或会话关闭后,迟到消息不能再次触发业务副作用。日志只写受控的操作名、状态码和有限的请求 ID,避免顺手把 token、手机号或 URL 查询参数带进 HiLog。

二、先区分 ArkWeb 的能力开放与业务授权

Web 组件可以开启 JavaScript 执行,ArkTS 宿主可用 registerJavaScriptProxy 或 javaScriptProxy 让前端调用有限的宿主方法,也可通过 runJavaScript 或 runJavaScriptExt 反向通知页面。官方文档明确介绍了这些调用方向,但是否允许某个业务动作,仍是开发者定义的协议规则。把 .javaScriptAccess(true) 写上,不意味着所有页面脚本天然可信。

项目目录我拆成 CampaignPage.ets(ArkWeb 容器)、BridgePolicy.ets(方法和参数校验)、BridgeSessionStore.ets(会话及待处理回调)和 BridgeProxy.ets(暴露给 H5 的最小表面)。这样改过以后,业务权限表不再藏在页面里的一大段 switch,也不会每加一个活动就复制一个新的 Bridge 实现。

这类接入还要把浏览来源的判断放在最外层:仅允许指定 HTTPS 活动主站,并处理跳转和非预期导航。演示域名使用 https://campaign.example.com,它是示例地址,不是可访问的实际运营活动。正式部署应替换为经组织验证的域名,并根据页面加载路径配置拦截策略。需要强调:仅凭页面 URL 是允许域名,不能替代桥接参数验证;同源页面一旦被注入恶意脚本,照样可能请求你开放的接口。

三、把宿主入口缩成一个有格式的消息通道

最初我想直接给 H5 暴露 trackEvent() 和 requestClose() 两个方法。后来发现如果协议很快扩展,散落多个暴露方法会增加维护成本。最后我选择宿主只暴露一个 postMessage,消息里携带 method、sessionId、callbackId 和 params。注意这不是为了“更灵活地开放权限”,恰恰相反:统一入口必须先过白名单、结构校验,再进入业务分发。

第一段代码展示 Bridge 侧的基本数据契约与白名单。它解决“未知方法名怎样在业务执行前被拒绝”的问题:

// BridgePolicy.ets:业务自定义协议,不是系统 API
interface BridgeEnvelope {
  method: string
  sessionId: string
  callbackId: string
  params: Record<string, string>
}

class BridgePolicy {
  private readonly methods: string[] = ['trackEvent', 'requestClose']

  validate(raw: string, liveSession: string): string {
    if (raw.length > 2048) { return 'INVALID_PAYLOAD' }
    let data: BridgeEnvelope
    try {
      data = JSON.parse(raw) as BridgeEnvelope
    } catch (_) {
      return 'INVALID_JSON'
    }
    if (!data || typeof data.method !== 'string' ||
        typeof data.sessionId !== 'string' ||
        typeof data.callbackId !== 'string') {
      return 'INVALID_PAYLOAD'
    }
    if (data.sessionId !== liveSession) { return 'SESSION_EXPIRED' }
    if (!this.methods.includes(data.method)) { return 'BRIDGE_DENIED' }
    if (data.callbackId.length > 64) { return 'INVALID_CALLBACK' }
    if (data.method === 'trackEvent' &&
        (!data.params || !['join', 'view'].includes(data.params.event))) {
      return 'INVALID_EVENT'
    }
    return 'ALLOWED'
  }
}

上面是工程结构示例,正式上线仍应额外做深层 JSON 类型检查、字段枚举、事件节流、重放控制和鉴权。比如 params.event 以外如果包含大型嵌套对象,单靠 raw.length 限制不足以覆盖所有异常输入;实际工程应使用严格解析器或者自己明确检查每一层类型。

还有一个安全边界不能省略:H5 自己传入的 sessionId 只帮助区分“这是不是当前会话”,不是可信身份凭证。页面脚本有能力读取其所在上下文里的会话号,不能依靠它阻止同源恶意脚本。真正敏感的宿主功能应避免向不可信页面暴露,并辅以服务端权限校验、操作确认和受控来源策略。

四、用最小 Proxy 暴露入口,别把整个服务对象交出去

在 ArkTS 页面里,调用通常沿着 WebviewController 完成。为了减少桥接表面,代理对象只暴露一个同步方法 postMessage(),内部只做初步合法性判断与消息入队,耗时统计、网络请求或原生能力操作交给异步业务层。不能在 H5 同步调用路径里做长耗时处理,否则会拖住页面 JavaScript 的执行。

第二段代码把白名单接到 Web 组件。这里 BridgeProxy 与 BridgeSessionStore 是项目自定义类,代码旨在说明实际 API 调用位置与职责分界:

import { webview } from '@kit.ArkWeb'

class BridgeProxy {
  constructor(private readonly accept: (msg: string) => string) {}
  postMessage(payload: string): string {
    return this.accept(payload)
  }
}

@Entry
@Component
struct CampaignPage {
  private controller: webview.WebviewController = new webview.WebviewController()
  private sessionId: string = 'cb_20261009_02'
  private policy: BridgePolicy = new BridgePolicy()
  private proxy: BridgeProxy = new BridgeProxy((message: string) => {
    const decision = this.policy.validate(message, this.sessionId)
    console.info(`CampaignBridge decision=${decision}`)
    return decision // ALLOWED 只代表通过入口校验,后续仍需业务验权
  })

  build() {
    Web({ src: 'https://campaign.example.com', controller: this.controller })
      .javaScriptAccess(true)
      .javaScriptProxy({
        object: this.proxy,
        name: 'CampaignHost',
        methodList: ['postMessage'],
        controller: this.controller
      })
      .onPageBegin((event) => {
        console.info(`CampaignBridge navigation begin`)
      })
      .onPageEnd(() => {
        console.info('CampaignBridge page end')
      })
  }
}

实际项目中 this.proxy 的创建应结合工程编译限制安排在合适的初始化生命周期里,且不能把上面直接作为可运行的完整项目:自定义类、导入、权限配置和业务异步分发都需要补齐。为了避免代码示例制造不真实的安全承诺,这里强调:validate() 返回 ALLOWED 后,真实事件仍须经过权限检查、入队、幂等与消费确认;千万不要把初步校验的返回值当作业务完成回执。

其中 .onPageEnd() 说明主页面加载流程结束,不等于所有 iframe、异步接口、资源都已经完成,更不能理解成“可以开始无条件信任此页面”。官方 ArkWeb FAQ 对页面生命周期与异步资源时序都有特别提示。若 Web 渲染进程异常退出,应用还应保存必要状态并在用户可感知的方式下恢复,而不是只靠页面结束事件处理一切。

五、这次我故意留了一个不应被允许的方法

演示页设计了三个按钮:trackEvent、requestClose 和 openSystemSettings。前两个属于本轮约定的白名单:trackEvent 只接受有限事件名,例如 join;requestClose 表示 H5 请求应用退出活动页,宿主仍可结合当前状态决定是否真的关闭。第三个根本不是活动业务需要的能力,放在界面里只是为了验证陌生操作会被拒绝。

测试步骤很简单:先确保会话 cb_20261009_02 处于 BRIDGE_ACTIVE,触发 trackEvent(join),日志应该看到 TRACK_EVENT accepted;接着发送 openSystemSettings,接口应返回 BRIDGE_DENIED,且没有任何系统设置动作发生。最后触发 requestClose 时,要求业务记录完成后才执行关闭,这一步必须能够中止并防止重复关闭。

这是与文章字段对应的拟真 IDE 画面:中间分发器允许 trackEvent 和 requestClose,右侧同一会话显示 BRIDGE_ACTIVE,底部 HiLog 提示 BRIDGE_DENIED method=openSystemSettings sessionId=cb_20261009_02。红色标注用于帮助读者找到“判断位置”和“拒绝证据”。图中文字并不构成真实操作系统的检测报告,真正的实验应在实际工程上复测。

运行图展示三个按钮的业务预期:两个 ALLOWED,一个 BRIDGE_DENIED。这里也故意不把第三方 H5 页面做成一张泛泛的海报:用户可看到活动标题、按钮和会话号,开发者可看到调用判定。真正投放活动时,这些诊断信息应该默认隐藏,避免把内部会话标识与调试信息暴露给普通用户。

六、页面关闭后晚到的回调,必须成为无害事件

一开始我以为调用 requestClose() 后直接 pop 页面就够了。复现“退出后状态被改写”时才意识到,页面不在屏幕上,不等于消息回调和业务异步操作都自动终止。例如 H5 发起上报,ArkTS 把网络请求交给后台任务,用户随即关闭活动页;随后服务端返回,业务层的回调仍有机会拿着旧页面引用去更新状态。

我在 BridgeSessionStore 里引入了会话代次 generation、是否活动 active、待回调集合和终止原因。所有异步工作在发起时记住代次,结束时再次比对:当前会话已关闭,或者代次不相等,结果就记为 LATE_CALLBACK_IGNORED,不再发向原先的 UI。这个机制不能替代真正的取消操作,但能保证已经发生的迟到结果不越界。

第三段代码专门解决“在会话关闭之后返回的结果应该怎么办”:

interface PendingCallback {
  callbackId: string
  generation: number
}

class BridgeSessionStore {
  private active: boolean = true
  private generation: number = 1
  private pending: Map<string, PendingCallback> = new Map()

  track(callbackId: string): number {
    if (!this.active) { return -1 }
    this.pending.set(callbackId, { callbackId, generation: this.generation })
    return this.generation
  }

  canDeliver(callbackId: string, issuedGeneration: number): boolean {
    const item = this.pending.get(callbackId)
    return this.active && !!item &&
      item.generation === issuedGeneration &&
      issuedGeneration === this.generation
  }

  consume(callbackId: string): void { this.pending.delete(callbackId) }

  close(): void {
    this.active = false
    this.generation += 1
    this.pending.clear()
    console.info('CampaignBridge SESSION_CLOSED activeCallbacks=0')
  }
}

这段实现只表达会话防护的核心思路:close() 使所有旧代次失效并清空回调表;回包端必须先 canDeliver(),只有通过才调用 runJavaScript()。否则你虽然有一个 close() 函数,但异步任务完全不查询它,仍然会回调旧页面。生产中还需取消网络请求、释放监听、注销消息订阅,并确保会话存储对象没有被全局单例意外长期持有。

此处还涉及一个容易被忽视的竞态:canDeliver() 返回 true 后,真正投递给 Web 之前,用户可能正好退出页面。因此必须把校验和结果提交放入同一调度上下文,必要时在发送前再次检查会话。对安全敏感的行为,只有接收方仍活跃并且业务操作未被取消才应响应;否则宁可丢弃可重复拉取的统计回执,也不要写回错误的页面。

七、Native 回 H5 时,不能靠字符串拼接碰碰运气

另一个常见写法是:runJavaScript("callback('" + result + "')")。当结果里包含引号、反斜杠或换行时,这段脚本可能语法错误;如果参数来自不可信消息,还可能扩大脚本注入风险。因此我给回调统一做 JSON 序列化,不把任意字符串直接插进 JavaScript 代码里。

第四段代码说明返回结果如何与会话校验衔接:

interface BridgeReply {
  callbackId: string
  code: string
  message: string
}

function sendReplySafely(
  controller: webview.WebviewController,
  store: BridgeSessionStore,
  reply: BridgeReply, issuedGeneration: number
): void {
  if (!store.canDeliver(reply.callbackId, issuedGeneration)) {
    console.info(`CampaignBridge LATE_CALLBACK_IGNORED id=${reply.callbackId}`)
    return
  }
  const encoded = JSON.stringify(reply)
  const script = `window.onCampaignReply && window.onCampaignReply(${encoded});`
  controller.runJavaScript(script)
  store.consume(reply.callbackId)
}

即使使用 JSON 也不是万事大吉:消息的字段还要限制允许的字符、长度和结构,页面侧的 onCampaignReply 也不能把字符串直接赋给不安全的 HTML 属性。真实工程最好定义一份双方共用的协议 Schema,在发布前运行兼容性测试。若通信频率高或者需要较大二进制负载,应评估异步代理、runJavaScriptExt 及专门的传输策略,避免大量同步桥接调用堵塞 Web 脚本线程。

遇到页面加载超时、SSL 错误或渲染进程异常退出时,我会优先让本轮会话失效并给用户明确反馈,而不是对所有错误都自动刷新页面。自动刷新可能重新建立一个新的桥接对象,但旧任务的回调仍在路上;没有会话代次隔离时,越“自动恢复”越容易产生竞态。

八、把关键日志做成一张可以对照的时间线

这次演示定义的运行序列是:10:24:15 TRACK_EVENT 成功,10:24:17 BRIDGE_DENIED 拒绝陌生方法,10:25:58 SESSION_CLOSED,10:26:02 LATE_CALLBACK_IGNORED。会话 ID 在每一步都相同:cb_20261009_02。当页面关闭时,活跃回调归零;迟到结果只记日志、不再发给 H5。

图 04 与图 03 不同:它不再展示活动按钮,而是展示会话关闭、活跃回调 0、被拒绝的迟到回调 1,以及从 BRIDGE_ACTIVE 到 SESSION_CLOSED 的时间线。它可以用来讲解“生命周期不是一个单纯的页面返回事件”,更重要的是被遗留的异步链路有没有失效。注意这些数值属于固定测试样例,不是实际设备采集的性能统计。

我会要求开发同伴在真机上至少执行五组测试:正常调用、方法白名单拒绝、畸形 JSON、同会话快速关闭再打开、渲染进程异常后重新建立会话。第五组尤其重要:如果新会话与旧会话共享同一个全局回调 Map,旧结果很可能被投递到新页面。需要检查新的 generation 是否递增,旧 callbackId 是否完全失效。

九、白名单之外,还有几道边界不能省

来源控制:活动域名、页面跳转与子资源加载不是一回事。主页来源允许不代表其引入的所有第三方脚本可信,尤其是营销页经常加载统计 SDK。应该尽量减少接入的外部脚本数量,设置必要的内容安全策略与资源来源约束,并在宿主层控制导航到非白名单目的地。实现这些策略时要根据当前 ArkWeb SDK 的可用拦截回调区分“加载前拦截”和“主 frame 加载结束”,不要拿 onPageEnd 当安全关卡。

数据敏感性:H5 上报事件不能顺手附带用户隐私数据。最好只接受有限的事件枚举和匿名业务标识,服务端再决定是否接受该事件。Bridge 日志也不应该记录完整 URL、原始 payload 或敏感 token,尤其是在生产 Release 包中。

能力最小化:openSystemSettings 在这个活动业务里没有正当需求,因此本例直接拒绝。不要为了让 H5 调用方便,就把相册、位置、剪贴板、系统设置之类的原生能力做成任意字符串转发接口。一旦开放敏感能力,要为具体用户意图设计交互确认,而不是相信前端传来一个 allow=true 字段。

会话释放:关闭时需要把回调表、定时任务、事件监听和 Web 侧注册关系作为一组资源清理。不同 SDK 版本可能有不同注销接口和调用约束,不能凭空写一个不存在的 destroyProxy()。如果当前架构不能可靠卸载代理对象,至少要通过会话门控阻止其任何业务副作用,并在组件销毁时释放宿主引用;上线前查清目标版本的实际 API。

失败后用户体验:如果 JSBridge 返回 BRIDGE_DENIED,H5 应显示明确的“此操作不可用”,而不是无限重试。同样,SESSION_EXPIRED 应当引导页面重新进入合法会话,而不是用旧 token 重发一百次。对产品来说,这些细节比“调用成功的截图”更直接影响完成率。

十、验收的结论应该怎么写

本轮的目标不是宣称 CampaignBridge 已经完成第三方安全审计。更准确的说法是:在设计的测试路径下,未授权方法能在业务执行前被拒绝,过期会话回调不再被投递,宿主开放的能力被控制在两个明确业务动作之内。这三项可作为集成时的自动化回归条件。

代码中仍有刻意保留的演示简化:BridgePolicy 并非完整 Schema 校验器,示例域名不是生产环境,Proxy 注册后的异步业务队列需要工程自行补齐;真正的网络安全、内容安全策略、签名与身份验证也不在一张示意图中得到证明。这些边界不能因为文章展示了 BRIDGE_DENIED 就省略。

从工程复盘角度看,第三方 H5 接入的核心并不是“桥是否能打通”,而是桥开了哪些门、每扇门凭什么开、门关闭后还有没有旧消息能钻进来。当白名单、会话代次、日志和退出清理都说清楚,ArkWeb 才能成为可控的业务接入层,而不是一个只求连通的黑盒。

十一、失败注入比成功按钮更接近上线条件

在真实活动投放中,我更愿意花半天做一次故障注入。首先把网页模拟成连续发送同一 callbackId:第一条事件正常进入,第二条应该被业务层幂等记录或拒绝,而不是再次扣减库存、重复弹窗或者累计两次转化。因为同一个回调 ID 并不代表请求一定来自可信方,幂等检查必须落在业务事务边界,不能只把重复条目从界面隐藏。

第二组测试是先退出再返回。让宿主在调用已经入队、尚未收到结果时关闭会话,随后手动触发迟到回包。此时旧代次应得到 LATE_CALLBACK_IGNORED,活跃回调仍为 0;再次进入活动页后必须创建新会话,而不是恢复旧 Map。第三组是渲染进程异常:Web 页面卡死或内核退出时,先失效旧会话,再提供一个由用户确认的重新加载入口,避免回调穿越到新页面。

第四组专门用畸形输入测试 BridgePolicy。包括缺失 method、嵌套 JSON 超深、空 callbackId、事件值大小写变化、超长字符串,以及伪造与当前会话不同的 ID。预期结果不能只是“不崩溃”,而是每种异常都能映射到有限且稳定的错误码,且不执行任何业务能力。测试人员还应确认失败日志没有把原始消息体打印出来;否则刚挡住一次危险调用,又可能在日志里暴露了敏感字段。

最后,我会补一份发布前交接约定:H5 团队只消费稳定的接口文档,移动端维护白名单版本,活动替换要走灰度与回退,业务方负责核对授权动作和用户提示。只要其中任何一个角色可以绕过审批临时新增原生方法,之前写得再好的 BRIDGE_DENIED 都会被人为规避。技术防线需要跟发布流程一起落地,才谈得上真正可维护。

参考资料:华为官方 ArkWeb 页面加载与权限、应用调用前端页面函数、ArkWeb 页面生命周期 FAQ。文中白名单、会话号、回调协议、测试样例属于应用层设计,并非系统直接提供的安全承诺。图片均为拟真示意而非真机采集。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐