測試環境原本一直能正常存取 API,但將封存套件交給測試團隊後,卻立刻出現網路錯誤。另一種更危險的情況則正好相反:為了暫時繞過憑證問題,有人將 NSAllowsArbitraryLoads 留在發佈設定中。應用程式看似恢復正常,實際上卻放行了所有原本應由 ATS 阻擋的連線。要避免這兩類事故,不能只檢查版本庫中的 Info.plist,而應以雲端 Mac 實際建置出的 App 為準。
先定義門禁要檢查什麼
一套可執行的 ATS 門禁至少分為三層:靜態設定、主機端 TLS 交握,以及應用層請求。三層回答的問題各不相同,不能用一次成功的 curl 取代所有驗證。
| 層級 | 檢查對象 | 失敗時優先檢查 |
|---|---|---|
| 靜態設定 | App 內最終的 Info.plist | 建置設定、指令碼改寫、網域例外 |
| TLS 探測 | 雲端 Mac 到目標端點 | DNS、憑證鏈、通訊協定版本、重新導向 |
| 應用程式請求 | URLSession 實際行為 | ATS、工作階段設定、驗證與回應解析 |
首先明確訂出政策:發佈產物不得包含 NSAllowsArbitraryLoads;NSExceptionDomains 只能列出經過審查的網域;臨時例外必須註明負責人與移除條件;僅供本機除錯使用的設定不得混入 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:它會將影響範圍擴大至所有子網域,不能因為目前只有一個 API 就預設開啟。
建議將清單以 JSON 或 YAML 格式維護在版本庫中,並在審查時核對下列項目:
- 網域必須精確,不接受萬用字元式描述;
- 禁止降級至不符合專案基準的 TLS 版本;
- 禁止為了處理單一重新導向而放行整個父網域;
- 除錯用 API 只能加入 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 能力,仍須使用 URLSession 驗證 ATS 規則、重新導向與驗證流程。
哪些 ATS 設定應直接讓 CI 失敗?
應阻擋 NSAllowsArbitraryLoads、未列入核准清單的網域例外,以及誤入發佈產物的除錯專用放行設定。
使用雲端 Mac mini 執行下一項開發或建置任務
可從兩種 M4 設定、四種租用週期與五個可租用節點中選擇,實際可用狀態以控制台即時回傳為準。