WebView与原生JS交互:JSBridge生产级实现与安全防护

这是 「Android WebView 深度探索」 系列的第 4 篇。前三篇分别聊了内核选型、白屏诊断、离线包代理,今天我们直击工程师每天都要面对、又最容易被做歪的环节------JS 与 Native 怎么打通,并且打通得既快又安全

引子:那个在生产环境潜伏了三年的"幽灵接口"

先讲个真事。去年我们做安全自查,工具扫到一个 H5 页面调了一个叫 device.exec 的 Native 方法,传一段字符串进去就能拿到 IMEI、当前定位、剪贴板。

追代码追了一下午,结果是 2023 年某次大版本重构时,一个老接口被新协议覆盖了,但没删干净,新协议加了鉴权、白名单、防重放,老的那个 @JavascriptInterface 直接裸奔在 addJavascriptInterface 里------任何加载到这个 WebView 的页面,包括被劫持的第三方页面,都能调它。

这件事让我重新审视了一件事:JSBridge 不是"加个方法让 H5 调"那么简单。它是 H5 沙箱通往 Native 全权限世界的唯一一道门,门是怎么造的、谁有钥匙、来访者怎么验明正身------每一项做歪了,都是高危漏洞。

原理篇:三种通信方式,到底应该选哪个

JS 和 Native 通信,工程上能用的就这三种。我把它们拉到同一张表里对比:

方式 方向 序列化开销 主要风险
addJavascriptInterface JS → Native(直接) 极低 反射攻击 / 接口暴露
shouldOverrideUrlLoading JS → Native(伪 URL) 中(需 URL 编码) 参数长度限制 / 队列丢失
evaluateJavascript Native → JS(回调) 线程切换 / 注入时机

现代生产级 JSBridge 几乎都走组合拳 :调用方向 JS → Native 用 addJavascriptInterface(性能最好),回调方向 Native → JS 用 evaluateJavascript(API 19+ 支持回调)。shouldOverrideUrlLoading 现在主要是兜底------给老系统、WebView 内核异常时用。

@JavascriptInterface 的工作原理

很多人调了几年这个注解,但没真正搞懂它背后做了什么。简单讲:WebView 启动时,Chromium 在 V8 引擎里注入一个全局对象,这个对象的每个方法都被代理到 Java 反射调用上。注解的作用是告诉 WebView:"只有标了这个的方法才允许 JS 调"。

这里有个臭名昭著的历史包袱------Android 4.2(API 17)以下,没有这个注解保护 。任何挂上去的 Java 对象,所有 public 方法都对 JS 暴露,包括从 Object 继承下来的 getClass()。攻击者拿到 getClass().forName(...) 就能拿到 Runtime,进而执行 exec("rm -rf ...")------这就是 CVE-2012-6636。

2026 年了,4.2 以下的设备占比已经低到忽略,但是注解漏挂 这个低级错误每年还是会出现。我们 CI 里加了 lint 规则:所有放进 addJavascriptInterface 的类,public 方法必须 100% 有 @JavascriptInterface,否则 build 失败。这种事不能靠 review。

协议设计:JSBridge 的"七寸"在哪

协议设计是 JSBridge 最容易被低估的部分。我见过太多团队上来就堆 @JavascriptInterface------一个方法对应一个能力,几十个能力就几十个 Java 方法。三年后回看,全是技术债。

生产级协议必须满足这五个特性:

统一入口 :JS 侧只调一个方法 bridge.call(method, params, callback),所有命名空间在 params 里走。

消息 ID:每个调用带一个 callId,Native 异步执行完用 callId 找到 JS 端的 callback。

双向异步:JS 调 Native 异步,Native 主动 push 给 JS(事件订阅)也走同一套协议。

错误码标准化 :成功失败一律用 {code, message, data},code 0 成功,非 0 各档错误。

超时与降级:每次调用挂超时定时器,Native 没回就失败回调,避免 Promise 永远 pending 让 H5 业务卡死。

协议格式

json 复制代码
// JS → Native 请求
{
  "callId": "req_18a3",
  "namespace": "device",
  "method": "getInfo",
  "params": { "fields": ["os", "version"] },
  "timeout": 5000,
  "sign": "a3f5b8c2..."
}

// Native → JS 回调
{
  "callId": "req_18a3",
  "code": 0,
  "data": { "os": "Android", "version": "15" }
}

把命名空间从方法名里拆出来非常重要------一年后扩展第二批能力,老协议保持不变,只在 namespace 维度加。我们线上目前有 14 个命名空间、200 多个方法,统一入口让维护成本基本平的。

高性能实现:把序列化开销压到底

JSBridge 一秒钟可能被调几百次(想想滑动列表里曝光埋点)。性能这件事不能含糊。三个核心原则:

1. 别绕弯走 URL Scheme 。 老一代 JSBridge 用 iframe.src = "jsbridge://..." 触发 shouldOverrideUrlLoading,参数要 URL encode、还有 2KB 长度限制。@JavascriptInterface 走 V8 直通车,参数是字符串直接进 JNI------同样的调用,性能差一个数量级。

2. 序列化用 JSON 但收敛 。 别用 Gson 反射全库扫描,用 Moshi codegen 或者直接 org.json。我们做过基准:500 次/秒的调用频率下,Gson 占 12% CPU,Moshi codegen 占 3.4%,org.json 占 4.1%。

3. 线程切换最小化@JavascriptInterface 默认在 WebView IO 线程,纯计算的方法(如读 SharedPreferences、查内存缓存)不要切线程,直接同步返回。需要切的(IO、网络、数据库)才丢到协程或线程池。

Native 侧分发器骨架

kotlin 复制代码
class BridgeCore(
    private val webView: WeakReference<WebView>,
    private val registry: HandlerRegistry,
    private val guard: SecurityGuard
) {
  // JS 调入口(IO 线程)
  @JavascriptInterface
  fun call(payload: String) {
    val req = BridgeReq.parse(payload)
        ?: return
    if (!guard.verify(req)) {
      reply(req.callId, ERR_AUTH)
      return
    }
    val handler = registry.find(req.namespace, req.method)
        ?: return reply(req.callId, ERR_404)

    when (handler.runOn) {
      SAME_THREAD -> handler.invoke(req) { reply(req.callId, it) }
      IO          -> scope.launch(Dispatchers.IO) {
                       handler.invoke(req) { reply(req.callId, it) }
                     }
      MAIN        -> mainHandler.post {
                       handler.invoke(req) { reply(req.callId, it) }
                     }
    }
  }

  private fun reply(callId: String, res: BridgeRes) {
    val js = "window.__bridge.dispatch('${callId}', ${res.toJson()})"
    mainHandler.post {
      webView.get()?.evaluateJavascript(js, null)
    }
  }
}

注意 runOn 这个枚举:每个 handler 注册时声明自己适合在哪个线程跑------纯内存查表用 SAME_THREAD 省一次切换,要碰 UI 的(弹窗、相册)用 MAIN,剩下的丢 IO显式声明而不是统一切,是优化 P95 延迟的关键

安全防护:四道闸门一道都不能少

JSBridge 的安全是个系统工程,单点防护都会被绕过。我们生产环境实际跑的是这四道闸门叠加:

闸门 检查点 防的是什么
域名白名单 页面 URL 是否在 allowlist 外部页面调内部接口
方法分级鉴权 敏感方法要用户态 token 未登录态调隐私接口
参数签名 HMAC + 时间戳 + nonce 参数篡改 / 重放攻击
调用审计 敏感方法异步上报 事后取证 / 异常告警

域名白名单的正确做法

很多团队把域名校验写成 url.contains("xx.com")------这是新手陷阱 。攻击者只要构造 https://evil.com/xx.com 就过了。

正确的做法:拿到 webView.url 之后用 Uri.parse 解析出 host,然后精确匹配或后缀匹配(带点)host == "xx.com" || host.endsWith(".xx.com")。同时,校验时机必须在 call() 入口、不是页面加载时------因为 SPA 路由切换不会重走加载。

参数签名怎么签

签名的目的不是防偷窥(HTTPS 已经做了),是防重放和篡改。我们的方案是:

签名串 = HMAC-SHA256(SECRET, namespace + method + params_json + timestamp + nonce)。SECRET 由 Native 在 H5 加载时通过 evaluateJavascript 注入到 H5 的内存里,不落本地存储、不落任何持久化,关掉 WebView 就丢。timestamp 校验 5 秒漂移,nonce 进 LRU 去重------一个 nonce 用过就拉黑。

这套方案没法做到 100% 防破解(攻击者把 H5 整个挖回去逆向也能拿到 SECRET),但能让普通的中间人 + 自动化扫描器吃瘪,这已经是工程上的合理权衡。

JS 注入时机:那个让你怀疑人生的 BUG

最后讲一个非常隐蔽的坑:Bridge 的 JS 端代码什么时候注入

很多团队在 onPageFinished 里调 evaluateJavascript 注入 window.__bridge。这有两个问题:一是 onPageFinished 比 H5 业务代码晚执行,业务代码里的 window.__bridge.call(...) 会报 undefined;二是 SPA 内部路由跳转不触发这个回调。

正解是用 WebViewCompat.addDocumentStartJavaScript (API 25 起作为 AndroidX 1.5+ 的稳定 API)------这个方法会在每次新文档解析的最早期 注入脚本,比任何业务 JS 都早。老版本兼容上用 onPageStarted 兜底,足够覆盖 99% 设备。

less 复制代码
if (WebViewFeature.isFeatureSupported(
        WebViewFeature.DOCUMENT_START_SCRIPT)) {
  WebViewCompat.addDocumentStartJavaScript(
      webView,
      readAsset("bridge_runtime.js"),
      setOf("https://*.your-domain.com")
  )
}

注意第三个参数------限定注入的源 。如果不传或传 "*",等于把 bridge runtime 注给所有页面(包括 about:blank、第三方跳转),相当于第一道闸门白做了。

从零写一个 SDK:那些方法签名不会告诉你的事

如果你打算把上面这些拼成一个能在公司内推广的 SDK,下面这几条经验可能比代码更值钱:

把 SDK 切成核心 + 能力包 。核心只做协议解析、调度、安全;具体 API(拍照、定位、支付)通过注解处理器自动注册。这样业务接入只需要写自己的 Handler 类,不改核心。

给 H5 同学一个"能跑的 Mock" 。Web 端开发不能等你 App 包,所以 SDK 一定要附带 window.__bridge.mock 模式,所有方法本地返回假数据,供前端 dev 模式联调。

方法治理上协议先行 。新增能力先写 schema(OpenAPI 风格),评审过了再写代码。我们用一个 bridge.yaml 描述所有方法,CI 里跑一致性校验,Native 实现和 H5 类型定义全自动生成。

埋好可观测性。每次调用打:方法名、耗时、是否成功、错误码、来源 URL。线上 P95 飙了能 5 分钟定位是哪个方法、哪个域名、哪个版本。

预留逃生舱。SDK 配一个远程开关,敏感方法可以一键全网下线。出了 0day 漏洞不用等 App 发版。

收尾

回到开头那个潜伏三年的"幽灵接口"------上线时它通过了所有评审,因为没人去检查"老接口是不是还在 addJavascriptInterface 里"。这件事的根本教训是:JSBridge 是基础设施,不能靠人来守

本系列下一篇我们聊性能与稳定性------预热、复用池、崩溃防护。把 JSBridge 这扇门修好之后,整个 WebView 还有一战要打:怎么让它不卡、不崩、不漏内存。

下一篇将继续《Android WebView 深度探索》系列。

相关推荐
我命由我123455 小时前
Android 开发问题:MlKitException: An internal error occurred during initialization.
android·java·java-ee·android jetpack·android-studio·androidx·android runtime
Meteors.5 小时前
Android自定义 View 三核心方法详解
android
2501_916007475 小时前
前端开发常用软件与工具全面指南
android·ios·小程序·https·uni-app·iphone·webview
赏金术士6 小时前
Android Tinker 热修复集成与使用指南 1.9.15.2
android·热修复·tinker
2603_954138397 小时前
安卓误删文件先别慌!5个实用小技巧指南教你补救
android·智能手机
波诺波8 小时前
5-SOFA可变形的3D物体 5-elasticity.scn
android
2501_9159090610 小时前
iOS应用性能优化:十大策略提升用户体验与开发效率
android·ios·小程序·https·uni-app·iphone·webview
sun00770010 小时前
打通android全链路,网卡驱动, 内核 , 到上层hal, framework
android
awu的Android笔记10 小时前
Android VpnService:如何把所有流量导入用户态
android