Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/ios/CovenCave/CovenCave/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSAllowsArbitraryLoads</key>
<true/>
<false/>
</dict>
<key>CFBundleURLTypes</key>
<array>
Expand Down
16 changes: 13 additions & 3 deletions apps/ios/CovenCave/CovenCave/Networking/CaveConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 [] }
Expand All @@ -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
}

Expand Down
28 changes: 28 additions & 0 deletions apps/ios/CovenCave/CovenCaveTests/CaveConnectionTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
61 changes: 61 additions & 0 deletions scripts/mobile-tailscale-native.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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>|<\/dict>|<key>([^<]+)<\/key>|<(true|false)\/>/g;
let depth = 0;
let pendingKey;

for (const token of plistDictionary.matchAll(tokenPattern)) {
if (token[0] === "<dict>") {
depth += 1;
} else if (token[0] === "</dict>") {
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}<\\/key>\\s*<dict>`);
const match = keyPattern.exec(plist);
assert.ok(match, `expected ${key} dictionary in plist`);

const dictionaryStart = plist.indexOf("<dict>", match.index);
const tokenPattern = /<dict>|<\/dict>/g;
tokenPattern.lastIndex = dictionaryStart;
let depth = 0;
let token;
while ((token = tokenPattern.exec(plist))) {
depth += token[0] === "<dict>" ? 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, /<key>NSLocalNetworkUsageDescription<\/key>/);
assert.match(infoPlist, /CovenCave connects to your private Tailscale network/);
Expand All @@ -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,
/<key>NSAllowsArbitraryLoads(?:ForMedia|InWebContent)?<\/key>\s*<true\/>/,
"the native iOS app must not enable any global ATS arbitrary-load exception",
);
assert.doesNotMatch(
shippingAts,
/<key>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/);

Expand Down
Loading