Published on

当安全策略在客户端拦截:iOS ATS 与"模拟器能、真机不能"背后的三重隐身

Authors
  • avatar
    Name
    Jack Qin
    Twitter

我们排查网络问题时有一个根深蒂固的假设:请求会发出去,失败发生在路上或对端,所以总有日志、有抓包、有错误码可查。App Transport Security(ATS)打破的正是这个假设——它是一道在客户端、在请求离开设备之前就执行的安全策略。被它拒绝的请求根本没上路,于是服务端没有任何痕迹,所有"看路上和对端"的调试手段同时失灵。

本文不讲某个网络 bug 怎么修,而是想把"模拟器跑得好好的、一上真机功能就消失"这件事拆成一笔清晰的账:它为什么是三层隐身叠加的结果、每一层各自吃掉了什么调试信号、以及为什么修复的真源头是一个你以为重载就能生效、实则在构建时就冻结了的配置文件。理解这笔账,你面对的就不只是 ATS,而是一整类"安全/策略在客户端侧静默拦截"的问题。


三层隐身:症状为什么完全看不见

故障的表现五花八门——一个条件渲染的按钮(如"使用 Microsoft 登录",依赖一个 providers 接口)悄无声息地不渲染;一个列表一直空着,连错误提示都没有;登录页能加载,但所有请求都以同一种方式失败。最迷惑的是:开发工具里什么都看不到。三个机制层层叠加,吃掉了本该出现的每一个信号:

  1. iOS 的 NSURLSession 在请求离开设备之前就拒了它——所以后端根本没有日志。第一层信号(服务端可观测性)就此消失。
  2. RN 的 fetch 层把它当成一个普通网络错误抛出,而这个错误通常被外层 try/catch 兜住,回退到一个"安全"默认值。第二层信号(错误冒泡)被 fail-soft 吞掉。
  3. 这个回退默认值看起来就像一个合法的"该功能被禁用"状态——providers 接口失败 → 回退到"仅凭证登录" → Microsoft 按钮本就该不在。第三层信号(UI 异常)也消失了,因为故障被伪装成了一个正常的产品状态。

三层各自都"合理":客户端侧强制是 ATS 的设计;fail-soft 是网络代码的常规防御;按 providers 结果条件渲染是正确的产品逻辑。但叠在一起,它们把一次网络层拒绝翻译成了一个看起来正常的 feature flag——这正是这类问题最难排查的根源。


机制:ATS 拒的是什么,模拟器为什么躲过

iOS 默认的 ATS 策略会阻止明文 HTTP 请求。模拟器之所以没事,是因为它走的是 localhost,而项目里已经为 localhost 配了例外;真机指向同一个后端,但走的是局域网 IP(192.168.x.x10.x.x.x172.16-31.x.x),没有对应例外,于是被 NSURLSession 在本地直接掐掉。这就是"模拟器能、真机不能"分裂的根因——两者命中的是 ATS 例外表里不同(或缺失)的条目。

这里有一条能在 30 秒内定性的关键线索:同一个 URL,用真机上的 Safari 打开是能通的。Safari 在 dev 下有一套更宽松的 ATS 设置。所以"Safari 能、App 不能"几乎是 ATS 拦截的指纹——它把"网络通路问题"和"App 自身策略问题"干净地分开了:网络通(Safari 证明),是 App 的 ATS 在拦。


真源头:配置在构建时就被冻结了

修复方向不难——给 ATS 加一条局域网例外。但更隐蔽、也最常让人卡住的,是这条配置在哪一刻生效

app.json 是配置的唯一真源(source-of-truth)。Expo prebuild 在构建时把它编译进 ios/<AppName>/Info.plist,运行中的 .app 携带的是构建时烤进去的那份 Info.plist。换句话说,ATS 配置的生命周期属于原生构建,不属于 JS——而我们日常迭代的 Metro 热重载只动 JS。

改动需要的操作
编辑 app.json 的 ATS 字典pnpm expo prebuild --platform ios + 重新构建 + 重装 App
直接改 ios/<AppName>/Supporting/Expo.plist只需重新构建(但 prebuild --clean 会覆盖你的改动——源头必须放进 app.json
重载 Metro / 按 r没用。 Metro 只下发新 JS,原生配置在安装时就冻结了
冷启动 App没用。 同上

"我改了 app.json 怎么还是不行"最常见的答案就在这张表里:你只重载了 Metro,原生 Info.plist 根本没动。这是一个典型的配置层与迭代层错位——你以为在调的旋钮(JS 热重载)和问题所在的旋钮(构建期原生配置)是两条管线。


修复,以及它逼出的粒度判断

apps/mobile/app.json 里加 NSAllowsLocalNetworking

{
  "expo": {
    "ios": {
      "infoPlist": {
        "NSAppTransportSecurity": {
          "NSAllowsLocalNetworking": true, // ← 局域网调试必需
          "NSExceptionDomains": {
            "localhost": {
              "NSExceptionAllowsInsecureHTTPLoads": true,
            },
          },
        },
      },
    },
  },
}

真正值得记下的不是这段 JSON,而是它背后的粒度权衡——ATS 例外有三种粒度,选哪个是在"安全范围"和"调试便利"之间定价:

Key效果何时用
NSAllowsLocalNetworking: true允许向 RFC 1918 私网段(10/8172.16/12192.168/16)和 .local mDNS 主机发明文 HTTP。真机 debug build 连同 Wi-Fi 下的 Mac 时必需仅 dev,保持开启
NSExceptionDomains.localhost.…InsecureHTTPLoads: true专门允许向 localhost 发明文 HTTP仅模拟器需要(共享宿主机 loopback)
NSAllowsArbitraryLoads: true允许向任意域名(含公网)发明文 HTTP别用。 范围太大,Apple 审核会标记

NSAllowsLocalNetworking 是粒度刚好的那一档——比 NSAllowsArbitraryLoads 窄(不放公网),又比逐个列举每台 dev 机的 IP 宽(IP 随 DHCP/换网段而变,且每人不同)。别硬编码 LAN IP 进 NSExceptionDomains:租约一变、网络一换就失效。

改完先验证配置真的落到设备上,再去查 App 代码:

/usr/libexec/PlistBuddy -c "Print :NSAppTransportSecurity" \
  apps/mobile/ios/<AppName>/Info.plist

如果 NSAllowsLocalNetworking 不见了,说明 prebuild 没跑,或构建缓存塞了旧 .app——重新构建再继续。


把排查顺序倒过来

这类问题最大的时间浪费,是在确认网络通路之前就一头扎进 App 代码。正确的顺序是从外向内逐层证伪,App 代码放最后:

  1. Mac 上 lsof -nP -iTCP:<port> -sTCP:LISTEN——后端是监听在 *:<port>(所有网卡)还是只在 127.0.0.1
  2. Mac 上 curl http://<LAN-IP>:<port>/<endpoint>——后端在局域网地址上能通吗?
  3. 设备上的 Safari 打开 http://<LAN-IP>:<port>/<endpoint>——Mac↔设备通路通吗(同一 Wi-Fi、无 AP 隔离)?
  4. 对构建出的 Info.plistPlistBuddy -c "Print :NSAppTransportSecurity"——ATS 例外在吗?
  5. 以上四步全过,去看 App 代码。

Mac curl 通、设备 Safari 通、唯独 App 失败 → 就是 ATS。这条决策树本身比任何单个命令都重要:它强制你在每一层留下一个明确的"通/不通"判定,而不是在最贵的那一层(业务代码)瞎猜。


可迁移的那一层

抛开 iOS 和 Expo 的具体 API,真正可迁移的认知是:

任何在客户端侧强制的安全/策略层,都会让"失败"发生在你的可观测性之外。 ATS 只是一例——浏览器的 CORS/CSP、移动端的证书固定、企业 MDM 的网络策略、本地防火墙,都共享这个性质:拒绝发生在请求上路之前,服务端无痕,错误又常被上层 fail-soft 吞掉,最终伪装成一个"正常状态"。排查这类问题,与其在业务代码里找谁返回了空,不如先问:这次失败到底发生在哪一层?它有没有可能在请求离开本机之前就被拦了? 一旦怀疑客户端侧策略,"换个更宽松的客户端(如 Safari)试同一个 URL"往往是最快的定性手段。