본문 바로가기
C.W.K.
Stream
Lesson 03 of 06 · published

App Transport Security, 그리고 혼자 있어야 하는 키

~16 min · engine-at-home, ats, info-plist, networking, diagnostics

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"앱은 못 닿는데 curl은 멀쩡하면, 네트워크보다 ATS를 먼저 의심해."

ATS가 거절하는 것

App Transport Security는 Info.plist에 따로 적지 않는 한, 앱의 URLSessionWKWebView 연결이 HTTPS를 쓰게 강제해. 거절된 요청은 NSURLErrorAppTransportSecurityRequiresSecureConnection, 에러 -1022로 실패해. tailnet에 올라간 엔진의 HTTP 쌍둥이는 평범한 HTTP라서, 거기로 접속하는 클라이언트는 첫 요청부터 ATS에 걸려.

규칙은 직접 재보고 나면 믿기 쉬워. 레슨을 준비하면서 작은 검사 앱 하나를 ATS 키만 바꿔 여러 벌 만들고, macOS 26에서 똑같은 요청을 보내봤어.

  • 키 없음: 루프백으로 가는 평범한 HTTP는 됐어. 공개 호스트, IP 주소로 지정한 tailnet 동료 기기, tailnet 이름으로 가는 평범한 HTTP는 Mac 자기 이름까지 포함해서 전부 -1022로 실패했어.
  • NSAllowsLocalNetworking만: 루프백은 됐고 tailnet은 여전히 실패했어. ATS가 말하는 로컬에 tailnet 주소는 안 들어가.
  • NSAllowsArbitraryLoads만: 모든 목적지가 200으로 대답했어.
  • 두 키 같이: 공개 호스트와 tailnet이 다시 -1022로 실패했어.
  • 키 하나 없이 엔진의 HTTPS 쌍둥이로: 200.

혼자 있어야 하는 키

네 번째 결과는 애플 문서에 그대로 적혀 있어. iOS 10과 macOS 10.12부터, NSAllowsArbitraryLoadsForMedia, NSAllowsArbitraryLoadsInWebContent, NSAllowsLocalNetworking 중 하나라도 있으면 NSAllowsArbitraryLoads는 무시돼. 전체 허용 키 옆에 좁은 키를 하나 더 두면 이중 안전장치 같아 보이는데, 실은 첫 번째 안전장치를 조용히 떼어내는 거야. 두 키를 다 가진 가족 런처가 tailnet 엔진에 닿을 수 없다고 보고했는데, 같은 Mac에서 curl은 10 ms 만에 200을 받았어. 가족 규칙은 짧아. 예외 없이 HTTPS 쌍둥이를 쓰거나, 아니면 NSAllowsArbitraryLoads를 다른 키 없이 혼자 써. 가족 폰 앱 대부분은 전체 허용 키 하나만 들고 있어. 애플은 App Store 심사에서 그 키를 쓰는 사유를 요구하지만, 가족 빌드는 내부 TestFlight 테스터한테 가.

ATS에 대해 거짓말하는 도구들

curl은 ATS를 구현하지 않아서, 거기서 받은 200은 네트워크를 증명할 뿐 앱에 대해선 아무것도 증명 못 해. nscurl --ats-diagnostics는 검사하기 전에 URL을 https로 바꿔버려. 믿을 만한 검사는 진짜 앱과 똑같은 ATS 키를 가진 번들 앱뿐이야. 아래 스크립트가 만드는 게 바로 그거고.

WKWebView엔 함정이 하나 더 있어. 평범한 HTTP 페이지를 tailnet IP 주소로 띄우는 브라우저 셸 앱이 있었는데, 그 주소를 가진 Mac에선 됐고 다른 Mac에선 전부 -1022로 실패했어. WebKit의 ATS는 그 기계 자신의 인터페이스 주소만 봐주고, 원격 IP 리터럴은 안 봐줘. 그래서 엔진이 있는 Mac에서 테스트하면 버그가 가려져. NSAllowsArbitraryLoadsInWebContent는 예외를 웹 콘텐츠로만 좁히고, URLSession엔 ATS를 그대로 적용해. 그리고 NSExceptionDomains엔 이름뿐 아니라 주소나 범위도 적을 수 있어. macOS 26.6에서 재보니, 다른 키 없이 다른 Mac의 tailnet IP나 tailnet 범위 100.64.0.0/10NSExceptionAllowsInsecureHTTPLoads 예외를 주면 -1022가 200으로 바뀌었어. 두 키에 tailnet 이름과 같은 플래그를 더해도 200이 나왔고. 어느 쪽이든 대가는 주소나 호스트 이름이 번들 안에 고정된다는 거야.

Code

평범한 HTTP 쌍둥이에 접속해야 하는 클라이언트의 Info.plist·xml
<key>NSAppTransportSecurity</key>
<dict>
  <!-- Alone. Adding NSAllowsLocalNetworking, NSAllowsArbitraryLoadsInWebContent
       or NSAllowsArbitraryLoadsForMedia makes the system ignore this key. -->
  <key>NSAllowsArbitraryLoads</key>
  <true/>
</dict>
앱이 겪는 그대로 ATS 재기: 설정마다 검사 번들 하나씩·bash
#!/bin/zsh
# Build one tiny app per ATS configuration and ask each the same question.
set -euo pipefail
target=${1:?usage: ats-matrix.sh http://host:port/path}
cat > probe.swift <<'SWIFT'
import Foundation
@main struct ATSProbe {
    static func main() async {
        do {
            let (_, response) = try await URLSession.shared.data(from: URL(string: CommandLine.arguments[1])!)
            print("HTTP", (response as? HTTPURLResponse)?.statusCode ?? -1)
        } catch let error as URLError {
            print("URLError", error.code.rawValue)
        } catch {
            print("error", error)
        }
    }
}
SWIFT
swiftc -swift-version 6 -parse-as-library probe.swift -o probe-bin

make_app() {   # name, ATS keys as plist XML
  mkdir -p "$1.app/Contents/MacOS" && cp probe-bin "$1.app/Contents/MacOS/ATSProbe"
  cat > "$1.app/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>CFBundleIdentifier</key><string>com.example.atsprobe.$1</string>
  <key>CFBundleExecutable</key><string>ATSProbe</string>
  <key>CFBundlePackageType</key><string>APPL</string>
  <key>NSAppTransportSecurity</key><dict>$2</dict>
</dict></plist>
PLIST
  codesign --force --sign - "$1.app" 2>/dev/null
}
yes='<true/>'
make_app no-keys ''
make_app local-only "<key>NSAllowsLocalNetworking</key>$yes"
make_app blanket "<key>NSAllowsArbitraryLoads</key>$yes"
make_app blanket-plus-local "<key>NSAllowsArbitraryLoads</key>$yes<key>NSAllowsLocalNetworking</key>$yes"

for app in no-keys local-only blanket blanket-plus-local; do
  printf '%-20s ' "$app"; "./$app.app/Contents/MacOS/ATSProbe" "$target"
done

External links

Exercise

매트릭스 스크립트를 목적지 셋에 대고 돌려. 루프백에 띄운 평범한 HTTP 서버(python3 -m http.server 8765 --bind 127.0.0.1), 인터넷의 평범한 HTTP 사이트, 그리고 직접 운영하는 엔진의 HTTPS 쌍둥이야. 결과 열두 개를 표로 적어. 다음으로 Spark의 Info.plist와 iOS 타깃에서 생성된 plist를 열어서 각각 들어 있는 ATS 키를 전부 적어. 전체 허용 키와 좁은 키가 같이 있는 곳이 있으면 고쳐.
Hint
경로까지 포함한 전체 URL을 넘겨. 예를 들면 http://127.0.0.1:8765/. iOS Info.plist를 XcodeGen이 project.ymlinfo.properties로 만들어주는 구조라면, 키는 거기서 고치고 다시 생성해. 생성된 파일을 직접 고치면 다음 실행 때 덮어써지거든.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.