Skip to content
C.W.K.
Stream
Lesson 03 of 06 · published

App Transport Security and the Key That Must Be Alone

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

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"App unreachable, curl fine: think ATS before network."

What ATS Refuses

App Transport Security makes an app's URLSession and WKWebView connections use HTTPS unless the app's Info.plist says otherwise. A refused load fails with NSURLErrorAppTransportSecurityRequiresSecureConnection, error -1022. The engine's HTTP twin on the tailnet is plain HTTP, so a client that dials it meets ATS on its first request.

The rules are easier to trust once measured. For this lesson, copies of one tiny probe app were built with different ATS keys and asked the same questions on macOS 26:

  • No keys: plain HTTP to loopback worked. Plain HTTP to a public host, to a tailnet peer by address, and to a tailnet name (even the Mac's own) all failed with -1022.
  • NSAllowsLocalNetworking alone: loopback worked, and the tailnet still failed. A tailnet address is not what ATS means by local.
  • NSAllowsArbitraryLoads alone: every destination answered 200.
  • Both keys together: -1022 again for the public host and the tailnet.
  • The engine's HTTPS twin, with no keys at all: 200.

The Key That Must Be Alone

Apple's documentation states the fourth result plainly: since iOS 10 and macOS 10.12, NSAllowsArbitraryLoads is ignored if NSAllowsArbitraryLoadsForMedia, NSAllowsArbitraryLoadsInWebContent or NSAllowsLocalNetworking is present. Adding a narrower key next to the blanket one looks like belt and braces and silently removes the belt. A family launcher with both keys reported its tailnet engine as unreachable while curl from the same Mac got a 200 in 10 ms. The family rule is short: use the HTTPS twin and no exception, or NSAllowsArbitraryLoads with nothing beside it. Most of the family's phone apps carry the blanket key alone. Apple asks for a justification of that key in App Store review; the family's builds go to internal TestFlight testers.

Instruments That Lie About ATS

curl does not implement ATS, so a 200 from it proves the network and nothing about the app. nscurl --ats-diagnostics rewrites the URL to https before testing. The only faithful probe is a bundled app with the real app's ATS keys, which is what the script below builds.

WKWebView adds a trap of its own. A browser-shell app loading a plain-HTTP page by tailnet IP address worked on the Mac that owned that address and failed with -1022 on every other Mac: WebKit's ATS exempts the machine's own interface addresses, not remote IP literals, so testing on the engine's Mac hides the bug. NSAllowsArbitraryLoadsInWebContent scopes the exception to web content and leaves URLSession under full ATS, and NSExceptionDomains takes an address or a range as well as a name. Measured on macOS 26.6, an exception with NSExceptionAllowsInsecureHTTPLoads for a peer's tailnet IP, or for the tailnet range 100.64.0.0/10, turned -1022 into 200 with no other keys, and both keys plus the tailnet name with the same flag returned 200 too. Each works at the price of an address or hostname fixed inside the bundle.

Code

Info.plist for a client that must dial a plain-HTTP twin·xml
<key>NSAppTransportSecurity</key>
<dict>
  <!-- Alone. Adding NSAllowsLocalNetworking, NSAllowsArbitraryLoadsInWebContent
       or NSAllowsArbitraryLoadsForMedia makes the system ignore this key. -->
  <key>NSAllowsArbitraryLoads</key>
  <true/>
</dict>
Measure ATS the way the app experiences it: one probe bundle per configuration·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

Run the matrix script against three targets: a plain-HTTP server on loopback (python3 -m http.server 8765 --bind 127.0.0.1), a plain-HTTP site on the internet, and your engine's HTTPS twin. Record the twelve results in a table. Then open Spark's own Info.plist and its iOS target's generated plist, list every ATS key each one carries, and fix any that pairs the blanket key with a narrower one.
Hint
Pass the full URL including the path, for example http://127.0.0.1:8765/. If XcodeGen writes the iOS Info.plist from info.properties in project.yml, fix the key there and regenerate, because an edit to the generated file is overwritten on the next run.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.