mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 22:04:30 +00:00
Gateway: force loopback self-connections for local binds
This commit is contained in:
@@ -133,7 +133,7 @@ describe("callGateway url resolution", () => {
|
|||||||
expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18800");
|
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({
|
loadConfig.mockReturnValue({
|
||||||
gateway: { mode: "local", bind: "tailnet", tls: { enabled: true } },
|
gateway: { mode: "local", bind: "tailnet", tls: { enabled: true } },
|
||||||
});
|
});
|
||||||
@@ -142,18 +142,20 @@ describe("callGateway url resolution", () => {
|
|||||||
|
|
||||||
await callGateway({ method: "health" });
|
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" } });
|
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "tailnet" } });
|
||||||
resolveGatewayPort.mockReturnValue(18800);
|
resolveGatewayPort.mockReturnValue(18800);
|
||||||
pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1");
|
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({
|
loadConfig.mockReturnValue({
|
||||||
gateway: { mode: "local", bind: "lan", tls: { enabled: true } },
|
gateway: { mode: "local", bind: "lan", tls: { enabled: true } },
|
||||||
});
|
});
|
||||||
@@ -163,16 +165,18 @@ describe("callGateway url resolution", () => {
|
|||||||
|
|
||||||
await callGateway({ method: "health" });
|
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" } });
|
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "lan" } });
|
||||||
resolveGatewayPort.mockReturnValue(18800);
|
resolveGatewayPort.mockReturnValue(18800);
|
||||||
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
||||||
pickPrimaryLanIPv4.mockReturnValue("192.168.1.42");
|
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 () => {
|
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");
|
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({
|
loadConfig.mockReturnValue({
|
||||||
gateway: { mode: "local", bind: "lan", tls: { enabled: true } },
|
gateway: { mode: "local", bind: "lan", tls: { enabled: true } },
|
||||||
});
|
});
|
||||||
@@ -280,12 +284,12 @@ describe("buildGatewayConnectionDetails", () => {
|
|||||||
|
|
||||||
const details = buildGatewayConnectionDetails();
|
const details = buildGatewayConnectionDetails();
|
||||||
|
|
||||||
expect(details.url).toBe("wss://10.0.0.5:18800");
|
expect(details.url).toBe("wss://127.0.0.1:18800");
|
||||||
expect(details.urlSource).toBe("local lan 10.0.0.5");
|
expect(details.urlSource).toBe("local loopback");
|
||||||
expect(details.bindDetail).toBe("Bind: lan");
|
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({
|
loadConfig.mockReturnValue({
|
||||||
gateway: { mode: "local", bind: "lan" },
|
gateway: { mode: "local", bind: "lan" },
|
||||||
});
|
});
|
||||||
@@ -293,7 +297,10 @@ describe("buildGatewayConnectionDetails", () => {
|
|||||||
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
||||||
pickPrimaryLanIPv4.mockReturnValue("10.0.0.5");
|
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", () => {
|
it("prefers remote url when configured", () => {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
resolveStateDir,
|
resolveStateDir,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
||||||
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
|
||||||
import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js";
|
import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js";
|
||||||
import {
|
import {
|
||||||
GATEWAY_CLIENT_MODES,
|
GATEWAY_CLIENT_MODES,
|
||||||
@@ -21,7 +20,7 @@ import {
|
|||||||
resolveLeastPrivilegeOperatorScopesForMethod,
|
resolveLeastPrivilegeOperatorScopesForMethod,
|
||||||
type OperatorScope,
|
type OperatorScope,
|
||||||
} from "./method-scopes.js";
|
} from "./method-scopes.js";
|
||||||
import { isSecureWebSocketUrl, pickPrimaryLanIPv4 } from "./net.js";
|
import { isSecureWebSocketUrl } from "./net.js";
|
||||||
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||||
|
|
||||||
type CallGatewayBaseOptions = {
|
type CallGatewayBaseOptions = {
|
||||||
@@ -116,18 +115,10 @@ export function buildGatewayConnectionDetails(
|
|||||||
const remote = isRemoteMode ? config.gateway?.remote : undefined;
|
const remote = isRemoteMode ? config.gateway?.remote : undefined;
|
||||||
const tlsEnabled = config.gateway?.tls?.enabled === true;
|
const tlsEnabled = config.gateway?.tls?.enabled === true;
|
||||||
const localPort = resolveGatewayPort(config);
|
const localPort = resolveGatewayPort(config);
|
||||||
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
|
||||||
const bindMode = config.gateway?.bind ?? "loopback";
|
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 scheme = tlsEnabled ? "wss" : "ws";
|
||||||
const localUrl =
|
// Self-connections should always target loopback; bind mode only controls listener exposure.
|
||||||
preferTailnet && tailnetIPv4
|
const localUrl = `${scheme}://127.0.0.1:${localPort}`;
|
||||||
? `${scheme}://${tailnetIPv4}:${localPort}`
|
|
||||||
: preferLan && lanIPv4
|
|
||||||
? `${scheme}://${lanIPv4}:${localPort}`
|
|
||||||
: `${scheme}://127.0.0.1:${localPort}`;
|
|
||||||
const urlOverride =
|
const urlOverride =
|
||||||
typeof options.url === "string" && options.url.trim().length > 0
|
typeof options.url === "string" && options.url.trim().length > 0
|
||||||
? options.url.trim()
|
? options.url.trim()
|
||||||
@@ -142,11 +133,7 @@ export function buildGatewayConnectionDetails(
|
|||||||
? "config gateway.remote.url"
|
? "config gateway.remote.url"
|
||||||
: remoteMisconfigured
|
: remoteMisconfigured
|
||||||
? "missing gateway.remote.url (fallback local)"
|
? "missing gateway.remote.url (fallback local)"
|
||||||
: preferTailnet && tailnetIPv4
|
: "local loopback";
|
||||||
? `local tailnet ${tailnetIPv4}`
|
|
||||||
: preferLan && lanIPv4
|
|
||||||
? `local lan ${lanIPv4}`
|
|
||||||
: "local loopback";
|
|
||||||
const remoteFallbackNote = remoteMisconfigured
|
const remoteFallbackNote = remoteMisconfigured
|
||||||
? "Warn: gateway.mode=remote but gateway.remote.url is missing; set gateway.remote.url or switch gateway.mode=local."
|
? "Warn: gateway.mode=remote but gateway.remote.url is missing; set gateway.remote.url or switch gateway.mode=local."
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@@ -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" } });
|
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "tailnet" } });
|
||||||
resolveGatewayPort.mockReturnValue(18800);
|
resolveGatewayPort.mockReturnValue(18800);
|
||||||
pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1");
|
pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1");
|
||||||
|
|
||||||
const result = resolveGatewayConnection({});
|
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" } });
|
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "lan" } });
|
||||||
resolveGatewayPort.mockReturnValue(18800);
|
resolveGatewayPort.mockReturnValue(18800);
|
||||||
pickPrimaryLanIPv4.mockReturnValue("192.168.1.42");
|
pickPrimaryLanIPv4.mockReturnValue("192.168.1.42");
|
||||||
|
|
||||||
const result = resolveGatewayConnection({});
|
const result = resolveGatewayConnection({});
|
||||||
|
|
||||||
expect(result.url).toBe("ws://192.168.1.42:18800");
|
expect(result.url).toBe("ws://127.0.0.1:18800");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user