diff --git a/ios-qa/templates/StateServer.swift.template b/ios-qa/templates/StateServer.swift.template index b8441df16..9696ff372 100644 --- a/ios-qa/templates/StateServer.swift.template +++ b/ios-qa/templates/StateServer.swift.template @@ -97,10 +97,12 @@ public final class StateServer { try? bootToken.write(toFile: bootTokenPath, atomically: true, encoding: .utf8) try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bootTokenPath) - // 2. Log the boot token EXACTLY ONCE so the daemon can scrape it. - // The daemon will rotate immediately; this log line is dead within - // seconds. - logger.notice("gstack-ios-qa-bootstrap token=\(self.bootToken, privacy: .public) port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)") + // 2. Announce bootstrap WITHOUT the token. The daemon reads the boot + // token from the 0600 file above (copyFileFromAppContainer); the + // os_log line that used to carry it had no consumer and handed a + // live credential to anything reading the unified log during the + // launch window. Port/build stay for diagnostics. + logger.notice("gstack-ios-qa-bootstrap port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)") // 3. Bind both IPv6 and IPv4 loopback. CoreDevice tunnel uses IPv6; // local tooling may use IPv4. Never bind 0.0.0.0 or ::. @@ -149,7 +151,19 @@ public final class StateServer { let params = NWParameters.tcp params.allowLocalEndpointReuse = true - let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!) + // IPv4 has no CoreDevice tunnel path, so it binds strictly to + // loopback at the socket level; IPv6 keeps the wildcard bind and + // relies on the per-connection peer check below for tunnel peers. + let listener: NWListener + switch family { + case .ipv4: + params.requiredLocalEndpoint = NWEndpoint.hostPort( + host: NWEndpoint.Host("127.0.0.1"), + port: NWEndpoint.Port(rawValue: port)!) + listener = try NWListener(using: params) + case .ipv6: + listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!) + } listener.stateUpdateHandler = { [weak self] state in Task { @MainActor in if case .ready = state { diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift index b8441df16..9696ff372 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift @@ -97,10 +97,12 @@ public final class StateServer { try? bootToken.write(toFile: bootTokenPath, atomically: true, encoding: .utf8) try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bootTokenPath) - // 2. Log the boot token EXACTLY ONCE so the daemon can scrape it. - // The daemon will rotate immediately; this log line is dead within - // seconds. - logger.notice("gstack-ios-qa-bootstrap token=\(self.bootToken, privacy: .public) port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)") + // 2. Announce bootstrap WITHOUT the token. The daemon reads the boot + // token from the 0600 file above (copyFileFromAppContainer); the + // os_log line that used to carry it had no consumer and handed a + // live credential to anything reading the unified log during the + // launch window. Port/build stay for diagnostics. + logger.notice("gstack-ios-qa-bootstrap port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)") // 3. Bind both IPv6 and IPv4 loopback. CoreDevice tunnel uses IPv6; // local tooling may use IPv4. Never bind 0.0.0.0 or ::. @@ -149,7 +151,19 @@ public final class StateServer { let params = NWParameters.tcp params.allowLocalEndpointReuse = true - let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!) + // IPv4 has no CoreDevice tunnel path, so it binds strictly to + // loopback at the socket level; IPv6 keeps the wildcard bind and + // relies on the per-connection peer check below for tunnel peers. + let listener: NWListener + switch family { + case .ipv4: + params.requiredLocalEndpoint = NWEndpoint.hostPort( + host: NWEndpoint.Host("127.0.0.1"), + port: NWEndpoint.Port(rawValue: port)!) + listener = try NWListener(using: params) + case .ipv6: + listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!) + } listener.stateUpdateHandler = { [weak self] state in Task { @MainActor in if case .ready = state { diff --git a/test/ios-qa-stateserver-hardening.test.ts b/test/ios-qa-stateserver-hardening.test.ts new file mode 100644 index 000000000..8a816206a --- /dev/null +++ b/test/ios-qa-stateserver-hardening.test.ts @@ -0,0 +1,48 @@ +/** + * StateServer hardening pins (fork port wave 2, B3 + B5). + * + * B3: the boot token must never appear in an os_log statement. The daemon + * reads it from the 0600 app-container file (copyFileFromAppContainer in + * tunnel-bootstrap.ts); the old `token=\(self.bootToken, privacy: .public)` + * announce line had NO consumer and handed a live credential to anything + * reading the unified log during the launch window. + * + * B5: the IPv4 listener has no CoreDevice tunnel path, so it must bind to + * loopback at the socket level (requiredLocalEndpoint 127.0.0.1), not rely + * solely on the per-connection peer check. IPv6 keeps the wildcard bind for + * CoreDevice ULA peers by design. + * + * Pinned on BOTH the generated template and the fixture app copy so neither + * can drift back independently. + */ + +import { describe, test, expect } from "bun:test"; +import { readFileSync } from "fs"; +import { join } from "path"; + +const ROOT = join(import.meta.dir, ".."); +const COPIES = [ + "ios-qa/templates/StateServer.swift.template", + "test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift", +]; + +describe.each(COPIES)("StateServer hardening — %s", (rel) => { + const src = readFileSync(join(ROOT, rel), "utf-8"); + + test("no os_log statement interpolates the boot token (B3)", () => { + const logLines = src.split("\n").filter((l) => /logger\.(notice|info|error|debug|log)/.test(l)); + for (const line of logLines) { + expect(line).not.toContain("bootToken"); + } + // The bootstrap announce survives (diagnostics), token-free. + expect(src).toContain('gstack-ios-qa-bootstrap port='); + expect(src).not.toContain("gstack-ios-qa-bootstrap token="); + }); + + test("IPv4 listener binds loopback at the socket level (B5)", () => { + expect(src).toContain("requiredLocalEndpoint"); + expect(src).toContain('NWEndpoint.Host("127.0.0.1")'); + // IPv6 wildcard + peer-check path must survive (CoreDevice tunnel peers). + expect(src).toMatch(/case \.ipv6:\s*\n\s*listener = try NWListener\(using: params, on:/); + }); +});