HarmonyOS 安全跳转治理实战:App Linking、Want、白名单与参数校验

外部跳转是增长入口,也是安全入口。活动页、短信链接、浏览器打开、其他应用拉起,都可能通过链接进入你的应用。如果直接把外部参数塞进页面路由,就会出现错误页面、越权打开、脏参数导致崩溃、伪造链接绕过登录等问题。安全跳转治理的目标是把所有外部入口收敛到一个网关:先确认来源,再解析参数,最后分发到受控页面。

请添加图片描述

这篇文章解决四个问题:

  1. App Linking 和 Want 进入应用后如何统一接管。
  2. 域名、路径、参数、目标页四层如何逐级放行。
  3. 外部链接失败时如何回到可解释页面。
  4. 上线前如何覆盖伪造链接、脏参数和登录态缺失。

1. 外部入口必须收敛

不要在每个页面分别处理外部链接。所有来自浏览器、短信、其他应用、广告落地页的跳转,都应该先进入 LinkReceiver,再交给策略层处理。

请添加图片描述

阶段 处理内容 拦截原因
接收 读取 Want、URI、来源标记 没有 URI 或来源不明
域名 校验可信域名和 Scheme 防止伪造站点
参数 转换类型、限制长度、过滤空值 防止页面异常
分发 只允许白名单页面 防止打开内部页面

外部入口的原则是“默认拒绝,明确放行”。只要没有命中配置,就进入兜底页,而不是猜测用户想打开什么。

2. App Linking 资料边界和工程目录

HarmonyOS 提供 App Linking 能力,可通过经验证的链接拉起应用;AbilityKit 的 Want 负责承载启动目标和参数。工程上要把官方能力接入和业务路由拆开。

资料入口 工程落点
App Linking 概述 配置可验证域名和应用拉起关系
配置 App Linking 域名校验、路径规则和发布配置
Want 概述 接收外部拉起参数
启动 Ability 目标页面分发和异常处理

目录建议:

entry/src/main/ets/
  common/linking/LinkReceiver.ets
  common/linking/DomainPolicy.ets
  common/linking/ParamParser.ets
  common/linking/RouteDispatcher.ets
  common/linking/LinkFallback.ets
  pages/common/LinkErrorPage.ets

这套目录不是为了复杂化,而是为了让所有外部跳转有统一入口和统一日志。

3. LinkReceiver 只做原始输入归一化

接收层不要做业务判断。它只负责把 Want、URI、来源 App、时间戳归一化成一条外部请求。

export interface ExternalLinkInput {
  rawUri: string
  sourceBundle?: string
  receivedAt: number
}

export class LinkReceiver {
  fromWant(parameters: Record<string, Object | undefined>): ExternalLinkInput | undefined {
    const uri = parameters['uri']
    if (typeof uri !== 'string' || uri.length === 0) {
      return undefined
    }
    const source = parameters['sourceBundle']
    return {
      rawUri: uri,
      sourceBundle: typeof source === 'string' ? source : undefined,
      receivedAt: Date.now()
    }
  }
}

这段代码不打开页面,也不信任参数。它只是把外部输入放进治理链路,后面每一步都可以记录和回放。

4. DomainPolicy 校验可信域名

域名策略必须明确。不要用 contains('example.com') 这种写法,因为 evil-example.com 也可能命中。应该解析 URL 后做精确域名和协议判断。

export interface LinkPolicyResult {
  allowed: boolean
  reason?: string
  url?: URL
}

export class DomainPolicy {
  private readonly domains = ['app.example.com', 'www.example.com']

  verify(rawUri: string): LinkPolicyResult {
    try {
      const url = new URL(rawUri)
      if (url.protocol !== 'https:') {
        return { allowed: false, reason: '只允许 HTTPS 链接' }
      }
      if (!this.domains.includes(url.hostname)) {
        return { allowed: false, reason: '域名不在可信范围内' }
      }
      return { allowed: true, url }
    } catch (_) {
      return { allowed: false, reason: '链接格式不正确' }
    }
  }
}

域名校验是第一道门。只要域名不可信,后面的路径和参数都不应该继续处理。

5. ParamParser 做类型和范围转换

外部链接里的参数全部是字符串。页面需要数字、枚举、布尔值时,必须经过转换和范围限制。

export type LinkTarget = 'article' | 'order' | 'member'

export interface ParsedLink {
  target: LinkTarget
  id: string
  from: string
}

export class ParamParser {
  parse(url: URL): ParsedLink | undefined {
    const target = url.searchParams.get('target') as LinkTarget | null
    const id = url.searchParams.get('id')
    const from = url.searchParams.get('from') ?? 'external'
    if (!target || !['article', 'order', 'member'].includes(target)) {
      return undefined
    }
    if (!id || !/^[a-zA-Z0-9_-]{1,64}$/.test(id)) {
      return undefined
    }
    return { target, id, from: from.slice(0, 32) }
  }
}

这里把 ID 长度、字符集和目标页都限制住。这样页面拿到的是干净参数,而不是原始 URL。

6. RouteDispatcher 只允许白名单页面

分发层要把业务目标转换成内部路由。外部链接不能直接传内部页面路径,否则很容易打开调试页、管理页或未登录页面。

请添加图片描述

export interface SafeRoute {
  page: string
  params: Record<string, string>
  loginRequired: boolean
}

export class RouteDispatcher {
  dispatch(link: ParsedLink): SafeRoute {
    const map: Record<LinkTarget, SafeRoute> = {
      article: { page: 'pages/content/ArticlePage', params: { id: link.id }, loginRequired: false },
      order: { page: 'pages/order/OrderDetailPage', params: { orderNo: link.id }, loginRequired: true },
      member: { page: 'pages/member/MemberCenterPage', params: { source: link.from }, loginRequired: true }
    }
    return map[link.target]
  }
}

路由表要白名单化。以后新增外部入口时,只能在这里显式增加,而不是让 URL 自己决定页面路径。

7. 登录态缺失要有中转页

如果链接目标需要登录,不要直接丢弃。可以进入登录中转页,登录完成后再恢复安全路由。

export interface LoginRedirectPlan {
  needLogin: boolean
  route: SafeRoute
  reason?: string
}

export function buildRedirectPlan(route: SafeRoute, isLogin: boolean): LoginRedirectPlan {
  if (route.loginRequired && !isLogin) {
    return {
      needLogin: true,
      route,
      reason: '该页面需要登录后访问'
    }
  }
  return { needLogin: false, route }
}

这样用户从订单短信进入应用时,不会因为未登录直接看到错误页。登录成功后仍能回到原订单详情。

8. 失败兜底要保存排查信息

外部链接失败时,页面应该告诉用户原因,同时把原始 URI、来源、失败阶段记录下来,方便运营排查投放链接是否配置错误。

export interface LinkFailure {
  rawUri: string
  stage: 'receive' | 'domain' | 'param' | 'route'
  reason: string
  occurredAt: number
}

export class LinkFallback {
  buildFailure(rawUri: string, stage: LinkFailure['stage'], reason: string): LinkFailure {
    return { rawUri: rawUri.slice(0, 300), stage, reason, occurredAt: Date.now() }
  }
}

失败记录不要包含敏感字段。截断原始链接、过滤 Token 类参数,是外部跳转日志的基本要求。

9. 外部拉起验收动作

场景 操作 预期结果
可信链接 打开 https://app.example.com/open?target=article&id=1001 进入文章页
非可信域名 打开伪造域名链接 进入兜底页并提示域名不可信
脏参数 id 超长或包含特殊字符 链接被拦截,不进入业务页
需要登录 未登录打开订单链接 进入登录中转,登录后恢复订单页
未知目标 target=admin 不打开内部页面

可以用一个轻量函数保证外部路由不越界:

export function assertSafeRoute(route: SafeRoute): void {
  if (!route.page.startsWith('pages/')) {
    throw new Error('外部跳转只能分发到应用页面目录')
  }
  if (route.page.includes('Debug') || route.page.includes('Admin')) {
    throw new Error('外部跳转禁止打开内部管理页面')
  }
}

这个断言适合放在路由配置用例里,防止后续新增入口时误放行内部页面。

10. 跳转异常排查表

现象 优先查看 处理建议
浏览器不能拉起应用 App Linking 配置、域名验证、安装包版本 先确认域名和包名配置一致
进入错误页面 target 映射表 不允许 URL 直接传内部路径
页面参数异常 ParamParser 转换结果 所有页面只接收解析后的参数
投放链接大量失败 失败阶段日志 按阶段区分域名、参数、登录态问题
未登录用户流失 登录中转页 登录后恢复原安全路由

安全跳转复现场景:给读者一组可执行核验

App Linking 和 Want 跳转都要验证白名单、参数和兜底页。能跳转不代表安全,非法链接必须被拒绝。

核验维度 读者需要准备的证据
输入 页面入口、用户动作、关键参数
过程 日志、状态变化、异常分支
输出 UI 表现、回调结果、持久化结果
回归 同场景重复执行后的结果
interface SafeLinkReplayCase {
  uri: any
  matchedRule: any
  fallbackPath: any
  allowed: any
}

const replay77: SafeLinkReplayCase = {
  uri: 'sample',
  matchedRule: 'sample',
  fallbackPath: 'sample',
  allowed: 'sample',
}

function assertReplay77(item: SafeLinkReplayCase): void {
  if (!item.allowed && item.fallbackPath.length === 0) throw new Error('非法链接缺少兜底页')
}

这组核验把外部链接的白名单和兜底页一起验证,避免非法参数直接进入业务页面。

安全跳转攻击回放表:把文章方法变成可复现动作

安全跳转文章需要给读者一个攻击视角。建议构造未知 scheme、合法域名缺参数、伪造业务 id、重复跳转四种链接,观察是否进入兜底页。

回放动作 核验方式
未知 scheme 拒绝 准备输入、执行操作、记录结果、给出结论
缺参数兜底 准备输入、执行操作、记录结果、给出结论
伪造 id 不进入详情 准备输入、执行操作、记录结果、给出结论
重复跳转被限流 准备输入、执行操作、记录结果、给出结论

安全跳转的回放要故意输入异常链接。读者可以准备未知 scheme、合法域名但缺参数、伪造业务 id、重复打开四类链接,分别确认白名单、参数校验、兜底页和限流是否生效。只有非法输入被稳定拒绝,App Linking 和 Want 跳转才适合放到外部入口使用。

11. 小结:外部链接要先进安全网关

安全跳转治理的关键不是写更多路由,而是减少入口。所有外部链接先经过接收、域名、参数、路由四层,再决定是否进入页面。这样活动投放、短信唤起、App Linking、其他应用拉起都能走同一套规则,既保护内部页面,也让异常链接有据可查。

Logo

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

更多推荐