diff --git a/CHANGELOG.md b/CHANGELOG.md index aa54bbaa7bf..520195d56f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -166,7 +166,7 @@ Docs: https://docs.openclaw.ai - CLI/plugins: explain when a missing plugin command alias belongs to a bundled plugin that is disabled by default, including the `openclaw plugins enable ` repair command. (#76835) - Gateway/Bonjour: auto-start LAN multicast discovery only on macOS hosts while preserving explicit `openclaw plugins enable bonjour` startup elsewhere, so Linux servers and containers that do not need LAN discovery avoid default mDNS probing and watchdog churn. Refs #74209. - Gateway/macOS: stop `doctor` and LaunchAgent recovery from running `launchctl kickstart -k` after a fresh bootstrap, avoiding an immediate SIGTERM of the just-started gateway while still nudging already-loaded launchd jobs. Fixes #76261. Thanks @solosage1. -- Proxy/debugging: disable debug proxy CONNECT upstream forwarding while managed proxy mode is active unless `OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY=1` is explicitly set for approved local diagnostics. Thanks @jesse-merhi and @mjamiv. +- Proxy/debugging: disable debug proxy direct upstream forwarding for proxy requests and CONNECT tunnels while managed proxy mode is active unless `OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY=1` is explicitly set for approved local diagnostics. Thanks @jesse-merhi and @mjamiv. - Google Meet: route stateful CLI session commands through the gateway-owned runtime so joined realtime sessions survive after the starting CLI process exits. Fixes #76344. Thanks @coltonharris-wq. - Memory/status: split builtin sqlite-vec store readiness from embedding-provider readiness in `memory status --deep` and `openclaw status`, so local vector-store failures no longer look like provider failures and provider failures no longer hide a healthy local vector store. - CLI/doctor: trust a ready gateway memory probe when CLI-side active memory backend resolution is unavailable, preventing false "No active memory plugin is registered" warnings for healthy runtime setups. Fixes #76792. Thanks @som-686. diff --git a/docs/cli/proxy.md b/docs/cli/proxy.md index 46391e50933..f60f52b685a 100644 --- a/docs/cli/proxy.md +++ b/docs/cli/proxy.md @@ -68,7 +68,7 @@ semantics. - `start` defaults to `127.0.0.1` unless `--host` is set. - `run` starts a local debug proxy and then runs the command after `--`. -- The debug proxy's CONNECT forwarding opens upstream TCP sockets for diagnostics. When OpenClaw managed proxy mode is active, CONNECT forwarding is disabled by default; set `OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY=1` only for approved local diagnostics. +- The debug proxy's direct upstream forwarding opens upstream sockets for diagnostics. When OpenClaw managed proxy mode is active, direct forwarding for proxy requests and CONNECT tunnels is disabled by default; set `OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY=1` only for approved local diagnostics. - `validate` exits with code 1 when proxy config or destination checks fail. - Captures are local debugging data; use `openclaw proxy purge` when finished. diff --git a/docs/security/network-proxy.md b/docs/security/network-proxy.md index 8bad3e72e22..72a26b9cc52 100644 --- a/docs/security/network-proxy.md +++ b/docs/security/network-proxy.md @@ -194,7 +194,7 @@ proxy: - The proxy improves coverage for process-local JavaScript HTTP and WebSocket clients, but it is not an OS-level network sandbox. - Raw `net`, `tls`, and `http2` sockets, native addons, and child processes may bypass Node-level proxy routing unless they inherit and respect proxy environment variables. - IRC is a raw TCP/TLS channel outside operator-managed forward proxy routing. In deployments that require all egress through that forward proxy, set `channels.irc.enabled=false` unless direct IRC egress is explicitly approved. -- The local debug proxy is diagnostic tooling and its CONNECT upstream forwarding is disabled by default while managed proxy mode is active; enable direct CONNECT forwarding only for approved local diagnostics. +- The local debug proxy is diagnostic tooling and its direct upstream forwarding for proxy requests and CONNECT tunnels is disabled by default while managed proxy mode is active; enable direct forwarding only for approved local diagnostics. - User local WebUIs and local model servers should be allowlisted in the operator proxy policy when needed; OpenClaw does not expose a general local-network bypass for them. - Gateway control-plane proxy bypass is intentionally limited to `localhost` and literal loopback IP URLs. Use `ws://127.0.0.1:18789`, `ws://[::1]:18789`, or `ws://localhost:18789` for local direct Gateway control-plane connections; other hostnames route like ordinary hostname-based traffic. - OpenClaw does not inspect, test, or certify your proxy policy. diff --git a/src/proxy-capture/proxy-server.managed-proxy.test.ts b/src/proxy-capture/proxy-server.managed-proxy.test.ts index bd2b707d5b0..f64a5d77d5e 100644 --- a/src/proxy-capture/proxy-server.managed-proxy.test.ts +++ b/src/proxy-capture/proxy-server.managed-proxy.test.ts @@ -1,9 +1,10 @@ import { mkdtemp, rm } from "node:fs/promises"; -import { Socket } from "node:net"; +import { createServer as createHttpServer } from "node:http"; +import { Socket, type AddressInfo } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { assertDebugProxyDirectConnectAllowed, startDebugProxyServer } from "./proxy-server.js"; +import { assertDebugProxyDirectUpstreamAllowed, startDebugProxyServer } from "./proxy-server.js"; let testRoot: string | undefined; @@ -47,7 +48,60 @@ async function connectThroughProxy(proxyUrl: string): Promise { return data; } -describe("debug proxy managed-proxy CONNECT policy", () => { +async function requestThroughProxy(proxyUrl: string, targetUrl: string): Promise { + const proxy = new URL(proxyUrl); + const target = new URL(targetUrl); + const socket = new Socket(); + let data = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk) => { + data += chunk; + }); + await new Promise((resolve, reject) => { + socket.once("error", reject); + socket.connect(Number(proxy.port), proxy.hostname, resolve); + }); + socket.write(`GET ${target.href} HTTP/1.1\r\nHost: ${target.host}\r\nConnection: close\r\n\r\n`); + await new Promise((resolve) => socket.once("end", resolve)); + socket.destroy(); + return data; +} + +async function startCanaryOrigin(): Promise<{ + requestCount: () => number; + stop: () => Promise; + url: string; +}> { + let requests = 0; + const server = createHttpServer((_req, res) => { + requests += 1; + res.end("ok"); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + const address = server.address() as AddressInfo; + return { + requestCount: () => requests, + stop: async () => + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }), + url: `http://127.0.0.1:${address.port}/metadata`, + }; +} + +describe("debug proxy managed-proxy direct upstream policy", () => { const originalProxyActive = process.env["OPENCLAW_PROXY_ACTIVE"]; const originalAllowDirect = process.env["OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY"]; @@ -73,31 +127,31 @@ describe("debug proxy managed-proxy CONNECT policy", () => { await cleanupTestDirs(); }); - it("allows direct CONNECT upstreams when managed proxy mode is inactive", () => { - expect(() => assertDebugProxyDirectConnectAllowed()).not.toThrow(); + it("allows direct upstreams when managed proxy mode is inactive", () => { + expect(() => assertDebugProxyDirectUpstreamAllowed()).not.toThrow(); }); - it("rejects direct CONNECT upstreams while managed proxy mode is active", () => { + it("rejects direct upstreams while managed proxy mode is active", () => { process.env["OPENCLAW_PROXY_ACTIVE"] = "1"; - expect(() => assertDebugProxyDirectConnectAllowed()).toThrow( - /Debug proxy CONNECT upstream forwarding is disabled/, + expect(() => assertDebugProxyDirectUpstreamAllowed()).toThrow( + /Debug proxy direct upstream forwarding is disabled/, ); }); it("uses shared truthy parsing for managed proxy mode", () => { process.env["OPENCLAW_PROXY_ACTIVE"] = "true"; - expect(() => assertDebugProxyDirectConnectAllowed()).toThrow( - /Debug proxy CONNECT upstream forwarding is disabled/, + expect(() => assertDebugProxyDirectUpstreamAllowed()).toThrow( + /Debug proxy direct upstream forwarding is disabled/, ); }); - it("allows direct CONNECT upstreams with explicit diagnostic override", () => { + it("allows direct upstreams with explicit diagnostic override", () => { process.env["OPENCLAW_PROXY_ACTIVE"] = "1"; process.env["OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY"] = "1"; - expect(() => assertDebugProxyDirectConnectAllowed()).not.toThrow(); + expect(() => assertDebugProxyDirectUpstreamAllowed()).not.toThrow(); }); it("rejects CONNECT upstreams before opening direct sockets while managed proxy mode is active", async () => { @@ -108,9 +162,26 @@ describe("debug proxy managed-proxy CONNECT policy", () => { expect(response).toContain("403 Forbidden"); expect(response).toContain("Connection: close"); - expect(response).toContain("Debug proxy CONNECT upstream forwarding is disabled"); + expect(response).toContain("Debug proxy direct upstream forwarding is disabled"); } finally { await server.stop(); } }); + + it("rejects absolute-form HTTP proxy requests before opening direct upstreams while managed proxy mode is active", async () => { + process.env["OPENCLAW_PROXY_ACTIVE"] = "1"; + const origin = await startCanaryOrigin(); + const server = await startDebugProxyServer({ settings: await makeSettings() }); + try { + const response = await requestThroughProxy(server.proxyUrl, origin.url); + + expect(response).toContain("403 Forbidden"); + expect(response).toContain("Connection: close"); + expect(response).toContain("Debug proxy direct upstream forwarding is disabled"); + expect(origin.requestCount()).toBe(0); + } finally { + await server.stop(); + await origin.stop(); + } + }); }); diff --git a/src/proxy-capture/proxy-server.ts b/src/proxy-capture/proxy-server.ts index 116f412c6fd..3ea799b4710 100644 --- a/src/proxy-capture/proxy-server.ts +++ b/src/proxy-capture/proxy-server.ts @@ -24,12 +24,12 @@ function allowsDirectConnectWithManagedProxy(env: NodeJS.ProcessEnv = process.en return isTruthyEnvValue(env[DEBUG_PROXY_DIRECT_CONNECT_OVERRIDE]); } -export function assertDebugProxyDirectConnectAllowed(env: NodeJS.ProcessEnv = process.env): void { +export function assertDebugProxyDirectUpstreamAllowed(env: NodeJS.ProcessEnv = process.env): void { if (!isManagedProxyActive(env) || allowsDirectConnectWithManagedProxy(env)) { return; } throw new Error( - "Debug proxy CONNECT upstream forwarding is disabled while managed proxy mode is active. " + + "Debug proxy direct upstream forwarding is disabled while managed proxy mode is active. " + `Set ${DEBUG_PROXY_DIRECT_CONNECT_OVERRIDE}=1 only for approved local diagnostics.`, ); } @@ -99,6 +99,33 @@ export async function startDebugProxyServer(params: { const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { const flowId = randomUUID(); const target = normalizeTargetUrl(req); + try { + assertDebugProxyDirectUpstreamAllowed(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + store.recordEvent({ + sessionId: params.settings.sessionId, + ts: Date.now(), + sourceScope: "openclaw", + sourceProcess: params.settings.sourceProcess, + protocol: target.protocol === "https:" ? "https" : "http", + direction: "local", + kind: "error", + flowId, + method: req.method, + host: target.host, + path: `${target.pathname}${target.search}`, + errorText: message, + }); + const responseBody = `${message}\n`; + res.writeHead(403, { + Connection: "close", + "Content-Type": "text/plain; charset=utf-8", + "Content-Length": Buffer.byteLength(responseBody), + }); + res.end(responseBody); + return; + } const body = await readBody(req); store.recordEvent({ sessionId: params.settings.sessionId, @@ -214,7 +241,7 @@ export async function startDebugProxyServer(params: { headersJson: JSON.stringify(req.headers), }); try { - assertDebugProxyDirectConnectAllowed(); + assertDebugProxyDirectUpstreamAllowed(); } catch (error) { const message = error instanceof Error ? error.message : String(error); store.recordEvent({