diff --git a/apps/ios/CovenCave/CovenCave/Info.plist b/apps/ios/CovenCave/CovenCave/Info.plist
index c949420d1..69507e198 100644
--- a/apps/ios/CovenCave/CovenCave/Info.plist
+++ b/apps/ios/CovenCave/CovenCave/Info.plist
@@ -62,7 +62,7 @@
NSAllowsLocalNetworking
NSAllowsArbitraryLoads
-
+
CFBundleURLTypes
diff --git a/apps/ios/CovenCave/CovenCave/Networking/CaveConnection.swift b/apps/ios/CovenCave/CovenCave/Networking/CaveConnection.swift
index a3a642ebe..c61e05b19 100644
--- a/apps/ios/CovenCave/CovenCave/Networking/CaveConnection.swift
+++ b/apps/ios/CovenCave/CovenCave/Networking/CaveConnection.swift
@@ -14,8 +14,17 @@ struct CaveConnection: Codable, Equatable {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
- // Already a full URL? Use it.
+ // Already a full URL? MagicDNS hosts always use HTTPS. A pasted
+ // `http://*.ts.net` URL would otherwise be rejected by ATS (and derive
+ // an insecure `ws://` terminal URL), despite Tailscale Serve issuing a
+ // certificate for the host.
if trimmed.lowercased().hasPrefix("http://") || trimmed.lowercased().hasPrefix("https://") {
+ if var components = URLComponents(string: trimmed),
+ components.scheme?.lowercased() == "http",
+ components.host?.lowercased().hasSuffix(".ts.net") == true {
+ components.scheme = "https"
+ return components.url
+ }
return URL(string: trimmed)
}
@@ -48,7 +57,8 @@ struct CaveConnection: Codable, Equatable {
/// terminates TLS on `:8443`, so a `.ts.net` host typed without a port
/// (which resolves to plain `:443`) never connects; we probe `:8443` and
/// relocate to it. A fully-qualified `http(s)://…` URL is trusted verbatim
- /// (the user was explicit), so it gets no alternates.
+ /// (the user was explicit), so it gets no alternates. Explicit HTTP
+ /// MagicDNS URLs are normalized to HTTPS before the single candidate is returned.
var candidateBaseURLs: [URL] {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
@@ -61,7 +71,7 @@ struct CaveConnection: Codable, Equatable {
let lower = trimmed.lowercased()
if lower.hasPrefix("http://") || lower.hasPrefix("https://") {
- if let url = URL(string: trimmed) { out.append(url) }
+ if let url = baseURL { out.append(url) }
return out
}
diff --git a/apps/ios/CovenCave/CovenCaveTests/CaveConnectionTests.swift b/apps/ios/CovenCave/CovenCaveTests/CaveConnectionTests.swift
new file mode 100644
index 000000000..958cc8541
--- /dev/null
+++ b/apps/ios/CovenCave/CovenCaveTests/CaveConnectionTests.swift
@@ -0,0 +1,28 @@
+import XCTest
+@testable import CovenCave
+
+final class CaveConnectionTests: XCTestCase {
+ func testExplicitHTTPMagicDNSURLUpgradesToHTTPS() {
+ let connection = CaveConnection(host: "http://cave.tailnet.example.ts.net:8443/api?source=pairing")
+
+ XCTAssertEqual(
+ connection.baseURL?.absoluteString,
+ "https://cave.tailnet.example.ts.net:8443/api?source=pairing"
+ )
+ XCTAssertEqual(
+ connection.wsBaseURL?.absoluteString,
+ "wss://cave.tailnet.example.ts.net:8443/api?source=pairing"
+ )
+ XCTAssertEqual(
+ connection.candidateBaseURLs.map(\.absoluteString),
+ ["https://cave.tailnet.example.ts.net:8443/api?source=pairing"]
+ )
+ }
+
+ func testExplicitHTTPRawIPRemainsAvailableForLocalNetworking() {
+ let connection = CaveConnection(host: "http://100.101.102.103:3000")
+
+ XCTAssertEqual(connection.baseURL?.absoluteString, "http://100.101.102.103:3000")
+ XCTAssertEqual(connection.wsBaseURL?.absoluteString, "ws://100.101.102.103:3000")
+ }
+}
diff --git a/scripts/mobile-tailscale-native.test.mjs b/scripts/mobile-tailscale-native.test.mjs
index fa600b7b8..b3ee3edde 100644
--- a/scripts/mobile-tailscale-native.test.mjs
+++ b/scripts/mobile-tailscale-native.test.mjs
@@ -4,6 +4,46 @@ import fs from "node:fs";
const read = (path) => fs.readFileSync(path, "utf8");
const packageJson = JSON.parse(read("package.json"));
+function directBooleanValues(plistDictionary) {
+ const values = {};
+ const tokenPattern = /|<\/dict>|([^<]+)<\/key>|<(true|false)\/>/g;
+ let depth = 0;
+ let pendingKey;
+
+ for (const token of plistDictionary.matchAll(tokenPattern)) {
+ if (token[0] === "") {
+ depth += 1;
+ } else if (token[0] === "") {
+ depth -= 1;
+ } else if (token[1] && depth === 1) {
+ pendingKey = token[1];
+ } else if (token[2] && depth === 1 && pendingKey) {
+ values[pendingKey] = token[2] === "true";
+ pendingKey = undefined;
+ }
+ }
+
+ return values;
+}
+
+function dictionaryForKey(plist, key) {
+ const keyPattern = new RegExp(`${key}<\\/key>\\s*`);
+ const match = keyPattern.exec(plist);
+ assert.ok(match, `expected ${key} dictionary in plist`);
+
+ const dictionaryStart = plist.indexOf("", match.index);
+ const tokenPattern = /|<\/dict>/g;
+ tokenPattern.lastIndex = dictionaryStart;
+ let depth = 0;
+ let token;
+ while ((token = tokenPattern.exec(plist))) {
+ depth += token[0] === "" ? 1 : -1;
+ if (depth === 0) return plist.slice(dictionaryStart, tokenPattern.lastIndex);
+ }
+
+ throw new Error(`unterminated ${key} dictionary in plist`);
+}
+
const infoPlist = read("src-tauri/gen/apple/app_iOS/Info.plist");
assert.match(infoPlist, /NSLocalNetworkUsageDescription<\/key>/);
assert.match(infoPlist, /CovenCave connects to your private Tailscale network/);
@@ -21,6 +61,27 @@ assert.doesNotMatch(
const sourceInfoPlist = read("src-tauri/Info.ios.plist");
assert.equal(sourceInfoPlist.trimEnd(), infoPlist.trimEnd());
+const shippingInfoPlist = read("apps/ios/CovenCave/CovenCave/Info.plist");
+const shippingAts = dictionaryForKey(shippingInfoPlist, "NSAppTransportSecurity");
+assert.deepEqual(
+ directBooleanValues(shippingAts),
+ {
+ NSAllowsLocalNetworking: true,
+ NSAllowsArbitraryLoads: false,
+ },
+ "the native iOS app must retain only ATS's narrow local-network allowance and keep global arbitrary loads disabled",
+);
+assert.doesNotMatch(
+ shippingAts,
+ /NSAllowsArbitraryLoads(?:ForMedia|InWebContent)?<\/key>\s*/,
+ "the native iOS app must not enable any global ATS arbitrary-load exception",
+);
+assert.doesNotMatch(
+ shippingAts,
+ /NSExceptionDomains<\/key>/,
+ "the native iOS app must not add ATS exception domains to the shipping configuration",
+);
+
const entitlements = read("src-tauri/gen/apple/app_iOS/app_iOS.entitlements");
assert.match(entitlements, /com\.apple\.developer\.networking\.wifi-info/);