前置阅读:01~09 篇
本篇目标:补齐 ArkWeb 混合应用所有安全短板,满足鸿蒙应用市场安全检测要求,规避 JS 注入、越权、敏感信息泄露等线上高危漏洞。

前面 9 篇,我们把 ArkWeb 容器从基础搭建、JSBridge 双向通信、SPA 路由、请求拦截、离线包、文件上传、性能内存优化全部实现。功能全部跑通,但是功能可用 ≠ 安全可用。 很多混合项目,开发调试一切正常,提交鸿蒙应用市场审核直接驳回;或者上线后存在高危漏洞,存在信息泄露、JS 注入风险。 这一篇就是整套连载的收尾终章,专门讲 ArkWeb 安全加固、风险点、最小权限原则、审计日志、防调试、防篡改,把容器打磨到符合企业安全规范、应用商店上架标准。

一、ArkWeb 混合开发最常见的几类高危安全漏洞

先盘点实际项目中最容易踩的安全坑,很多开发者完全没有意识到风险:

  1. JS 注入漏洞:H5 页面可通过 runJavaScript 执行任意原生代码,窃取 APP 本地存储、设备敏感信息
  2. JSBridge 无校验:任意 H5 页面都可以调用所有原生接口,不受域名白名单限制,加载恶意 H5 就会越权
  3. 权限过度申请:一次性申请相机、位置、文件读写全部权限,应用市场审核直接打回
  4. 敏感信息裸传:Token、用户凭证直接注入 Web 页面,H5 可直接拿到凭证
  5. 允许访问 file 协议:恶意 H5 读取 APP 沙箱本地文件,泄露本地数据
  6. 调试模式未关闭:正式包保留 Web 调试开关,外部可通过 Chrome 远程调试页面
  7. 未做证书校验:HTTPS 页面不校验证书,中间人劫持,替换 H5 页面
  8. 无操作审计:JSBridge 调用、文件上传、接口拦截没有日志,出现安全问题无法溯源

核心安全原则:不信任任何 H5 页面。哪怕是自己业务 H5,也要做严格限制,假设 H5 已经被篡改。

二、基础安全配置:Web 组件开关管控,关闭不必要能力

ArkWeb 很多能力默认开启,上线前必须按需关闭,减少攻击面。

​
Web({
  src: this.currentUrl,
  controller: this.webController
})
.javaScriptAccess(true) // 业务需要JS则开启,纯静态页面可以直接关闭
.domStorageAccess(true) // 按需开启localStorage
.databaseAccess(false)  // 不使用Web SQL数据库,直接关闭
.fileAccess(false)     // 禁止file协议访问本地文件,高危!关闭
.imageAccess(true)
// 关闭Web调试,生产环境必关
.webDebuggingAccess(false)

​

重点说明:

  1. .webDebuggingAccess(false):关闭远程调试。打包 release 包必须关闭,开启会被安全扫描判定为高危风险。开发阶段临时打开调试,上线代码务必移除。
  2. .fileAccess(false):禁止 H5 访问本地文件,防止恶意页面读取沙箱数据。
  3. JavaScriptAccess:如果你的 H5 页面完全静态、不需要 JS 交互,直接设置 false,从根源杜绝 JS 注入。

三、最重要:JSBridge 域名白名单 + 接口权限隔离

这是混合应用安全的重中之重。 默认情况下,只要加载了 H5,H5 就能调用所有注册到 nativeBridge 的方法。如果你的页面跳转到外部域名、被劫持跳转到恶意页面,对方可以直接调用全部原生能力。

解决方案:域名白名单,只允许可信域名的 H5 调用原生 Bridge 接口;非白名单域名,直接禁用全部 JSBridge 能力。

3.1 域名白名单实现代码

​
// 可信业务域名列表,只保留自己的业务域名
const TRUST_HOST_LIST = [
  "https://business.example.com",
  "https://static.example.com",
  "https://app-local" // 离线包虚拟域名
];

// 判断当前页面域名是否可信
function isTrustDomain(url: string): boolean {
  return TRUST_HOST_LIST.some(host => url.startsWith(host));
}

​

在页面跳转、onLoadStatusChange 中监听当前页面地址,非可信域名,注销 JSBridge 代理。

​
.onLoadStatusChange((status) => {
  if(status === WebLoadStatus.FinishLoading){
    const currentUrl = this.webController.getUrl();
    if(isTrustDomain(currentUrl)){
      // 可信域名,注册bridge
      this.webController.registerJavaScriptProxy(
        this.jsBridge,
        "nativeBridge",
        ["getDeviceInfo", "showNativeToast", "pushSpaRoute"]
      )
    }else{
      // 外部不可信域名,删除JSBridge,禁止调用原生能力
      this.webController.deleteJavaScriptProxy("nativeBridge");
      console.warn("当前域名不在白名单,关闭JSBridge能力", currentUrl);
    }
  }
})

​

3.2 JSBridge 入参校验,防注入

就算是可信域名,H5 传递的参数也不能直接信任。所有传入参数必须校验类型、过滤特殊字符,不能直接丢进 runJavaScript。 改造 JSBridge 方法示例,增加参数校验:

​
class JsBridgeModel {
  // 不安全写法:直接接收参数不校验
  // async getNativeData(params){ ... }

  // 安全版本:增加参数校验
  async getNativeData(params: Record<string, Object>) {
    // 参数类型校验
    if (!params || typeof params !== "object") {
      return {code: -1, msg: "参数非法"};
    }
    // 过滤危险字符,防止注入
    if(JSON.stringify(params).includes("javascript:")){
      return {code: -1, msg: "参数包含非法内容"};
    }
    // 业务逻辑...
    return {code:0, data:{}}
  }
}

​

禁止做法:不要直接拼接 H5 传入的字符串到 runJavaScript,极易造成 JS 注入。

四、权限最小化:动态权限、按需申请,用完释放

前面第 9 篇我们用到相机、媒体读取权限。安全规范要求:不要在 APP 启动一次性申请全部权限,用到的时候再申请,不用时不持有权限。

错误做法:module.json5 一次性申请相机、位置、文件、媒体所有权限,APP 打开就弹窗索要全部权限,应用市场审核扣分。 正确流程:

  1. 用户点击上传拍照按钮时,才动态申请 CAMERA 权限
  2. 用户取消上传,不持有权限
  3. 不需要的权限,不要在配置文件声明
​
import { abilityAccessCtrl, Permissions } from '@kit.AccessibilityKit';

// 动态申请权限示例
async function requestPermission(permission: Permissions):Promise<boolean>{
  const atManager = abilityAccessCtrl.createAtManager();
  let result = await atManager.requestPermissionsFromUser(getContext(), [permission]);
  return result.authResults[0] === 0;
}

​

业务逻辑:点击拍照时,先调用 requestPermission 申请 CAMERA,用户同意,再唤起相机。

权限分级策略:

  • 基础页面:不需要任何敏感权限
  • 文件上传模块:仅在用户点击上传时申请 READ_MEDIA
  • 拍照上传:点击拍照才申请 CAMERA
  • 定位相关 H5:打开定位页面时才申请位置权限

五、HTTPS 证书校验,防中间人劫持

如果 H5 页面使用 HTTPS,需要校验服务端证书,防止中间人攻击劫持网络,替换 H5 页面。 通过 onSslError 捕获证书异常,证书不合法,直接阻断页面加载,拒绝继续访问。

​
.onSslError((event)=>{
  console.error("SSL证书异常", event.error);
  // 证书错误,拦截页面加载,不继续展示页面
  event.handled = true;
  this.loadStatus = "页面证书不安全,访问已拦截";
  return true;
})

​

不要在生产环境写忽略证书错误的代码,这个是高危漏洞,安全扫描直接拦截上架。仅本地开发调试临时使用。

六、敏感数据保护:Token、用户信息不要直接暴露给 H5

很多项目直接把登录 token 注入 H5 页面,一旦 H5 被篡改,token 直接被盗取。 推荐方案:

  1. JSBridge 接口按需返回数据,H5 主动拉取,而不是页面初始化一次性注入全部凭证
  2. 接口 Token 放在原生请求头(前面第 6 篇 onResourceLoad 注入 Header),H5 拿不到完整 Token
  3. Token 设置短期有效期,就算泄露,风险窗口有限
  4. 用户退出登录,立刻清除 Web 缓存、Cookie,销毁 Web 实例

退出登录清理代码:

​
// 用户登出,清理Web所有会话缓存
async function clearWebSession(webController:WebController){
  await webController.clearCache(true);
  await webController.clearAllSslCache();
  webController.deleteJavaScriptProxy("nativeBridge");
}

​

七、安全审计日志:操作记录留存,方便溯源

生产环境建议增加审计日志,记录所有高风险操作:

  • JSBridge 调用记录(调用方法、调用域名、参数摘要,不要记录完整敏感参数)
  • 文件上传记录:上传时间、文件类型、文件大小
  • 页面跳转记录,记录跳转域名
  • SSL 异常、拦截页面访问日志

简易审计日志示例:

​
// 审计日志工具,只记录摘要,避免记录隐私明文
function writeSecurityLog(type:string, msg:string){
  const log = `[SECURITY][${new Date().toISOString()}] type:${type}, info:${msg}`;
  console.info(log);
  // 可扩展:上报到后端安全日志平台
}

​

在 JSBridge 入口、onShowFileSelector 上传触发、证书拦截位置插入日志。

八、常见安全检测不通过的坑点 & 修复方案

表格

风险项问题描述修复方案
Web 远程调试开启release 包 webDebuggingAccess=true生产包关闭调试开关
File 访问开启fileAccess=true,允许 H5 读取本地文件fileAccess(false)
JSBridge 无域名白名单任意网页都可以调用原生接口增加可信域名列表,外部域名删除 bridge
权限过度申请一次性申请大量敏感权限按需动态申请权限
忽略 SSL 证书错误代码忽略 ssl 异常,允许不安全 HTTPSonSslError 拦截,禁止访问
明文传递 token 给 H5H5 可直接拿到登录凭证原生注入请求头,H5 无法获取 token
不校验 JS 入参H5 任意参数直接执行参数类型校验,过滤危险字符

九、完整集成安全加固后的最终页面代码

整合前面 9 篇的容器能力,叠加本篇全部安全策略,最终可提交上架的完整 ArkWeb 页面。

​
import { Web, WebController, WebLoadStatus } from '@kit.ArkWeb';
import { router } from '@ohos/router';
import { JsBridgeModel, SpaRouteItem } from './JsBridgeModel';
import { abilityAccessCtrl, Permissions } from '@kit.AccessibilityKit';

// 可信域名白名单
const TRUST_HOST_LIST = [
  "https://business.example.com",
  "https://static.example.com",
  "https://app-local"
];

@Entry
@Component
struct ArkWebSecurePage {
  private webController: WebController = new WebController();
  private jsBridge: JsBridgeModel = new JsBridgeModel();
  @State spaRouteStack: SpaRouteItem[] = [];
  @State loadStatus: string = "页面待加载";

  aboutToAppear() {
    this.jsBridge.onSpaRouteChange = (route) => {
      if (route.isReplace) this.spaRouteStack.pop();
      this.spaRouteStack.push(route);
    };
  }

  onPageShow() {
    this.webController.resume();
  }

  onPageHide() {
    this.webController.pause();
  }

  aboutToDisappear() {
    this.jsBridge.h5Callback = null;
    this.jsBridge.onSpaRouteChange = null;
    this.spaRouteStack = [];
    this.webController.destroy();
  }

  private handleSpaBack() {
    this.spaRouteStack.length > 1 ? (this.spaRouteStack.pop(), this.webController.runJavaScript(`history.back()`)) : router.back();
  }

  build() {
    Column() {
      Text(`SPA栈深度:${this.spaRouteStack.length} | ${this.loadStatus}`)
        .fontSize(14)
        .margin(10)

      Web({
        src: "https://business.example.com/index.html",
        controller: this.webController
      })
      .width('100%')
      .height('88%')
      .javaScriptAccess(true)
      .fileAccess(false) // 禁止本地文件访问
      .webDebuggingAccess(false) // 关闭远程调试,生产必关
      .databaseAccess(false)
      // SSL证书校验
      .onSslError((event) => {
        console.error("SSL证书不安全,阻断访问");
        this.loadStatus = "页面证书不安全,访问拦截";
        event.handled = true;
        return true;
      })
      // 页面加载完成,校验域名白名单,控制JSBridge注册
      .onLoadStatusChange((status) => {
        if(status === WebLoadStatus.FinishLoading){
          const url = this.webController.getUrl();
          if(TRUST_HOST_LIST.some(host => url.startsWith(host))){
            writeSecurityLog("TRUST_DOMAIN", `加载可信页面:${url}`);
            this.webController.registerJavaScriptProxy(
              this.jsBridge,
              "nativeBridge",
              ["getDeviceInfo", "showNativeToast", "pushSpaRoute"]
            )
          }else{
            writeSecurityLog("UNTRUST_DOMAIN", `拦截外部页面bridge:${url}`);
            this.webController.deleteJavaScriptProxy("nativeBridge");
          }
          this.loadStatus = "页面加载完成";
        }else if(status === WebLoadStatus.ErrorLoading){
          this.loadStatus = "页面加载失败";
        }
      })
      // 文件上传钩子,继承第9篇上传逻辑,增加安全日志
      .onShowFileSelector((param) => {
        writeSecurityLog("UPLOAD_TRIGGER", "H5触发文件选择");
        return true;
      })
    }
    .width('100%')
    .height('100%')
    .onBackPressed(() => {
      this.handleSpaBack();
      return true;
    })
  }
}

// 安全审计日志
function writeSecurityLog(type:string, msg:string){
  const logStr = `[SECURITY][${new Date().toISOString()}] type:${type}, msg:${msg}`;
  console.info(logStr);
}

​

十、整套 10 篇系列全局验收清单(上线前一次性自检)

到这里 10 篇连载全部结束,上线前对照清单逐项检查: ✅ 基础容器:Web 组件生命周期,页面销毁 destroy,内存释放 ✅ JSBridge 双向通信:异步回调、参数校验、域名白名单 ✅ SPA 路由:双栈管理,返回键处理,修复白屏 ✅ 请求拦截:Header 注入、资源拦截、下载接管 ✅ 离线包:虚拟域名,rawfile 资源映射,SPA fallback ✅ 文件上传:onShowFileSelector,拍照图库,防抖防重复点击 ✅ 性能优化:内核预热、缓存策略、崩溃监听、内存泄漏防护 ✅ 安全加固:关闭调试、禁止 file 访问、SSL 校验、权限最小化、审计日志

全部检查通过,你的 ArkWeb 混合容器就可以交付测试、提交鸿蒙应用市场审核。

十一、终章总结

《ArkWeb 混合开发手记 01~10》全套连载到此正式完结。 我们从最简单 Web 组件创建起步,一步步搭建企业级鸿蒙混合容器:

  • 基础渲染、生命周期管理
  • JSBridge 双向通信,前端和鸿蒙原生互调
  • SPA 单页应用路由栈管理,解决返回键混乱、白屏问题
  • 请求拦截、请求头注入、下载接管
  • 离线包 + 虚拟域名,实现无网 H5 访问
  • H5 表单文件、拍照、图库上传原生接管
  • 性能优化、内存管控、内核崩溃防护
  • 最后安全加固,漏洞防护,满足上架安全检测

读完整套手记,你完全可以独立基于 HarmonyOS7 开发一套商用混合应用容器,覆盖绝大多数业务场景。

拓展延伸方向(番外可选)

  1. Web 页面截图、H5 调用原生扫码
  2. 多 Web 实例隔离,多窗口 H5 场景
  3. 埋点采集,H5 和原生统一埋点体系
  4. 灰度、版本控制、热更新全流程

Logo

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

更多推荐