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/);