MiniRent 工程指南

云端 Mac 的 iOS ATS 例外审计与 TLS 连通性门禁

云端 Mac 的 iOS ATS 例外审计与 TLS 连通性门禁

测试环境一直能访问接口,归档包交给测试团队后却立即报网络错误。另一种更危险的情况正好相反:为了临时绕过证书问题,有人把 NSAllowsArbitraryLoads 留在发布配置里,应用看似恢复正常,却把原本应该由 ATS 拦截的连接全部放行。要避免这两类事故,检查对象不能只是仓库里的 Info.plist,而应是云端 Mac 实际编译出的 App。

先定义门禁检查什么

一套可执行的 ATS 门禁至少分三层:静态配置、主机侧 TLS 握手和应用层请求。三层回答的问题不同,不能用一次 curl 成功代替全部验证。

层级 检查对象 失败时优先排查
静态配置 App 内最终 Info.plist 构建配置、脚本改写、域名例外
TLS 探测 云端 Mac 到目标端点 DNS、证书链、协议版本、重定向
应用请求 URLSession 实际行为 ATS、会话配置、鉴权与响应解析

先把策略写清楚:发布产物禁止 NSAllowsArbitraryLoadsNSExceptionDomains 中只能出现经过审查的域名;临时例外必须带负责人和删除条件;本地调试需要的配置不能混入 Release。

ATS 例外不是“让网络先跑起来”的通用开关,而是一项需要说明范围、原因和退出计划的安全变更。

审计最终构建产物

源码中的 plist 可能被 INFOPLIST_KEY_*、不同 xcconfig 或构建脚本覆盖。先完成一次 Release 构建,再读取产物路径:

set -euo pipefail

xcodebuild \
  -scheme "$SCHEME" \
  -configuration Release \
  -sdk iphonesimulator \
  -derivedDataPath "$PWD/.derived-data" \
  build

APP_PATH="$(find "$PWD/.derived-data/Build/Products" \
  -type d -name '*.app' -path '*Release-*' -print -quit)"

test -n "$APP_PATH"
PLIST="$APP_PATH/Info.plist"
plutil -lint "$PLIST"
plutil -extract NSAppTransportSecurity json -o - "$PLIST" \
  > "$PWD/ats-effective.json" 2>/dev/null || printf '{}
' > "$PWD/ats-effective.json"

门禁应把“没有 ATS 字典”和“存在空字典”视为正常情况,而不是报错。真正需要失败的是宽泛放行或未批准例外。下面的脚本以环境变量传入允许列表,避免把团队内部域名硬编码进公共脚本:

import json
import os
import sys

with open(sys.argv[1], encoding="utf-8") as f:
    ats = json.load(f)

if ats.get("NSAllowsArbitraryLoads") is True:
    raise SystemExit("NSAllowsArbitraryLoads is forbidden")

approved = {
    item.strip().lower()
    for item in os.getenv("ATS_APPROVED_DOMAINS", "").split(",")
    if item.strip()
}
exceptions = ats.get("NSExceptionDomains", {})

unknown = sorted(set(map(str.lower, exceptions)) - approved)
if unknown:
    raise SystemExit("Unapproved ATS domains: " + ", ".join(unknown))

运行时使用 python3 ci/audit_ats.py ats-effective.json。CI 日志可以保留键名和检查结果,但不要打印请求令牌、Cookie 或完整鉴权头。

给例外建立可审查清单

仅比较域名还不够。每个例外都应记录允许的 ATS 键、使用环境、原因和复查条件。尤其要留意 NSIncludesSubdomains:它会把影响范围扩展到所有子域,不能因为当前只有一个接口就默认开启。

建议把清单维护成版本库内的 JSON 或 YAML,并在评审时核对以下项目:

  • 域名必须精确,不接受通配式描述;
  • 禁止降低到不符合项目基线的 TLS 版本;
  • 禁止为了处理单个重定向而放行整个父域;
  • 调试接口只进入 Debug 配置;
  • 删除例外后必须重新执行构建与请求测试。

检查配置是否串线

分别构建 Debug 与 Release,然后导出两份 ats-effective.json 做差异比较。若 Release 出现只为抓包或本地服务准备的键,应直接失败。不要只比较源码文件,因为同一个 plist 也可能被不同构建设置注入不同结果。

探测 TLS 与重定向链

静态审计通过后,再从承担构建任务的云端 Mac 探测目标地址。nscurl 能输出 ATS 诊断矩阵,适合定位协议、证书链与前向保密问题:

test -n "${API_URL:-}"
/usr/bin/nscurl --ats-diagnostics "$API_URL" \
  > "$PWD/ats-diagnostics.txt" 2>&1

这份输出适合诊断,不适合简单地以“文件中出现 PASS”作为门禁,因为诊断模式会尝试多种放宽组合。CI 的硬判断应使用项目真实 URL 发起一次受控请求,并限制超时、重定向次数和响应码:

curl --fail --silent --show-error \
  --proto '=https' \
  --tlsv1.2 \
  --max-time 15 \
  --max-redirs 3 \
  --output /dev/null \
  "$API_URL"

curl 成功只说明主机侧链路可用。它不会应用 iOS App 的 ATS 配置,也不能证明应用的会话代理、请求头或鉴权逻辑正确。

补上应用层回归测试

最后增加一个使用生产网络栈的轻量测试目标。测试应读取测试环境注入的 URL,以 URLSession 发起健康检查,并断言响应完成、状态码符合约定且没有跳转到非 HTTPS 地址。不要在测试代码中写固定凭据。

保存足够但不过量的证据

失败时归档以下内容即可:最终 ATS 字典、域名清单比较结果、nscurl 输出、请求状态码、重定向目标的主机名,以及 Xcode 构建配置名称。证书正文、访问令牌和完整响应体通常没有必要进入长期日志。

把执行顺序固定为“Release 构建 → 最终 plist 审计 → TLS 探测 → URLSession 测试”。这样既能发现配置漂移,也能区分是 App 策略拒绝连接,还是目标端点的证书链、DNS 或重定向发生变化。

上线前检查表

合并前确认发布产物没有任意网络放行,所有域名例外都在批准清单中,子域范围经过明确评审,TLS 探测使用真实目标地址,应用层测试走实际 URLSession 配置。发生失败时先保留产物和诊断文件,再修改配置,避免用新增例外掩盖证书或重定向问题。

这套门禁不追求让所有网络故障自动消失,而是把故障定位到可处理的层级,并确保临时调试设置不会悄悄进入下一次发布。

常见问题

为什么不能只检查源码中的 Info.plist?

构建设置、配置文件和脚本都可能改变最终产物。门禁应检查已构建 App 内的 Info.plist,才能看到实际交付配置。

nscurl 诊断通过是否代表 App 一定能联网?

不能。它只能证明当前主机到目标端点的 TLS 能力,还需要用 App 的 URLSession 请求验证 ATS、重定向和鉴权路径。

哪些 ATS 配置应直接阻止合并?

通常应阻止 NSAllowsArbitraryLoads,以及未经白名单批准的域名例外;调试专用配置也不得进入发布产物。

独享物理设备

用云端 Mac mini 运行下一项开发或构建任务

从两档 M4 配置、四种租用周期和五个在售节点中选择,实际可用状态以控制台实时返回为准。

选择配置并租用