diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 80903d093f6..68464e4978d 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -133,7 +133,7 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18800"); }); - it("uses tailnet IP with TLS when local bind is tailnet", async () => { + it("uses loopback with TLS when local bind is tailnet", async () => { loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "tailnet", tls: { enabled: true } }, }); @@ -142,18 +142,20 @@ describe("callGateway url resolution", () => { await callGateway({ method: "health" }); - expect(lastClientOptions?.url).toBe("wss://100.64.0.1:18800"); + expect(lastClientOptions?.url).toBe("wss://127.0.0.1:18800"); }); - it("blocks ws:// to tailnet IP without TLS (CWE-319)", async () => { + it("uses loopback without TLS when local bind is tailnet", async () => { loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "tailnet" } }); resolveGatewayPort.mockReturnValue(18800); pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1"); - await expect(callGateway({ method: "health" })).rejects.toThrow("SECURITY ERROR"); + await callGateway({ method: "health" }); + + expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18800"); }); - it("uses LAN IP with TLS when bind is lan", async () => { + it("uses loopback with TLS when bind is lan", async () => { loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "lan", tls: { enabled: true } }, }); @@ -163,16 +165,18 @@ describe("callGateway url resolution", () => { await callGateway({ method: "health" }); - expect(lastClientOptions?.url).toBe("wss://192.168.1.42:18800"); + expect(lastClientOptions?.url).toBe("wss://127.0.0.1:18800"); }); - it("blocks ws:// to LAN IP without TLS (CWE-319)", async () => { + it("uses loopback without TLS when bind is lan", async () => { loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "lan" } }); resolveGatewayPort.mockReturnValue(18800); pickPrimaryTailnetIPv4.mockReturnValue(undefined); pickPrimaryLanIPv4.mockReturnValue("192.168.1.42"); - await expect(callGateway({ method: "health" })).rejects.toThrow("SECURITY ERROR"); + await callGateway({ method: "health" }); + + expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18800"); }); it("falls back to loopback when bind is lan but no LAN IP found", async () => { @@ -270,7 +274,7 @@ describe("buildGatewayConnectionDetails", () => { expect(details.message).toContain("Gateway target: ws://127.0.0.1:18789"); }); - it("uses LAN IP with TLS and reports lan source when bind is lan", () => { + it("uses loopback URL and loopback source when bind is lan", () => { loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "lan", tls: { enabled: true } }, }); @@ -280,12 +284,12 @@ describe("buildGatewayConnectionDetails", () => { const details = buildGatewayConnectionDetails(); - expect(details.url).toBe("wss://10.0.0.5:18800"); - expect(details.urlSource).toBe("local lan 10.0.0.5"); + expect(details.url).toBe("wss://127.0.0.1:18800"); + expect(details.urlSource).toBe("local loopback"); expect(details.bindDetail).toBe("Bind: lan"); }); - it("throws for ws:// to LAN IP without TLS (CWE-319)", () => { + it("uses loopback URL for bind=lan without TLS", () => { loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "lan" }, }); @@ -293,7 +297,10 @@ describe("buildGatewayConnectionDetails", () => { pickPrimaryTailnetIPv4.mockReturnValue(undefined); pickPrimaryLanIPv4.mockReturnValue("10.0.0.5"); - expect(() => buildGatewayConnectionDetails()).toThrow("SECURITY ERROR"); + const details = buildGatewayConnectionDetails(); + + expect(details.url).toBe("ws://127.0.0.1:18800"); + expect(details.urlSource).toBe("local loopback"); }); it("prefers remote url when configured", () => { diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 300a556436e..5713864a443 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -7,7 +7,6 @@ import { resolveStateDir, } from "../config/config.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; -import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js"; import { GATEWAY_CLIENT_MODES, @@ -21,7 +20,7 @@ import { resolveLeastPrivilegeOperatorScopesForMethod, type OperatorScope, } from "./method-scopes.js"; -import { isSecureWebSocketUrl, pickPrimaryLanIPv4 } from "./net.js"; +import { isSecureWebSocketUrl } from "./net.js"; import { PROTOCOL_VERSION } from "./protocol/index.js"; type CallGatewayBaseOptions = { @@ -116,18 +115,10 @@ export function buildGatewayConnectionDetails( const remote = isRemoteMode ? config.gateway?.remote : undefined; const tlsEnabled = config.gateway?.tls?.enabled === true; const localPort = resolveGatewayPort(config); - const tailnetIPv4 = pickPrimaryTailnetIPv4(); const bindMode = config.gateway?.bind ?? "loopback"; - const preferTailnet = bindMode === "tailnet" && !!tailnetIPv4; - const preferLan = bindMode === "lan"; - const lanIPv4 = preferLan ? pickPrimaryLanIPv4() : undefined; const scheme = tlsEnabled ? "wss" : "ws"; - const localUrl = - preferTailnet && tailnetIPv4 - ? `${scheme}://${tailnetIPv4}:${localPort}` - : preferLan && lanIPv4 - ? `${scheme}://${lanIPv4}:${localPort}` - : `${scheme}://127.0.0.1:${localPort}`; + // Self-connections should always target loopback; bind mode only controls listener exposure. + const localUrl = `${scheme}://127.0.0.1:${localPort}`; const urlOverride = typeof options.url === "string" && options.url.trim().length > 0 ? options.url.trim() @@ -142,11 +133,7 @@ export function buildGatewayConnectionDetails( ? "config gateway.remote.url" : remoteMisconfigured ? "missing gateway.remote.url (fallback local)" - : preferTailnet && tailnetIPv4 - ? `local tailnet ${tailnetIPv4}` - : preferLan && lanIPv4 - ? `local lan ${lanIPv4}` - : "local loopback"; + : "local loopback"; const remoteFallbackNote = remoteMisconfigured ? "Warn: gateway.mode=remote but gateway.remote.url is missing; set gateway.remote.url or switch gateway.mode=local." : undefined; diff --git a/src/tui/gateway-chat.test.ts b/src/tui/gateway-chat.test.ts index 53687c2c419..14f7e622118 100644 --- a/src/tui/gateway-chat.test.ts +++ b/src/tui/gateway-chat.test.ts @@ -93,23 +93,23 @@ describe("resolveGatewayConnection", () => { }); }); - it("uses tailnet host when local bind is tailnet", () => { + it("uses loopback host when local bind is tailnet", () => { loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "tailnet" } }); resolveGatewayPort.mockReturnValue(18800); pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1"); const result = resolveGatewayConnection({}); - expect(result.url).toBe("ws://100.64.0.1:18800"); + expect(result.url).toBe("ws://127.0.0.1:18800"); }); - it("uses lan host when local bind is lan", () => { + it("uses loopback host when local bind is lan", () => { loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "lan" } }); resolveGatewayPort.mockReturnValue(18800); pickPrimaryLanIPv4.mockReturnValue("192.168.1.42"); const result = resolveGatewayConnection({}); - expect(result.url).toBe("ws://192.168.1.42:18800"); + expect(result.url).toBe("ws://127.0.0.1:18800"); }); });