mirror of https://github.com/garrytan/gstack.git
fix(ios-qa): boot token out of os_log entirely; IPv4 listener pinned to loopback at the socket
The StateServer's bootstrap announce logged the live boot token with privacy: .public — and nothing consumed it: the daemon has read the token from the 0600 app-container file since the devicectl copy flow landed. The log line handed a credential to anything reading the unified log during the launch window. It now announces port/build only. The IPv4 listener bound the wildcard interface and relied on the per-connection peer check alone; IPv4 has no CoreDevice tunnel path, so it now binds 127.0.0.1 via requiredLocalEndpoint at the socket level. IPv6 keeps the wildcard bind for CoreDevice ULA peers by design. Static pins cover both the template and the fixture app copy. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f31aff1bc6
commit
a83292b1ed
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:/);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue