diff --git a/CHANGELOG.md b/CHANGELOG.md index 176602bfa55..4a5afc9480d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -167,6 +167,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 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 30237dd663f..f60f52b685a 100644 --- a/docs/cli/proxy.md +++ b/docs/cli/proxy.md @@ -68,6 +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 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 e8439fdc264..72a26b9cc52 100644 --- a/docs/security/network-proxy.md +++ b/docs/security/network-proxy.md @@ -194,6 +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 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 new file mode 100644 index 00000000000..f64a5d77d5e --- /dev/null +++ b/src/proxy-capture/proxy-server.managed-proxy.test.ts @@ -0,0 +1,187 @@ +import { mkdtemp, rm } from "node:fs/promises"; +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 { assertDebugProxyDirectUpstreamAllowed, startDebugProxyServer } from "./proxy-server.js"; + +let testRoot: string | undefined; + +async function cleanupTestDirs(): Promise { + if (!testRoot) { + return; + } + const root = testRoot; + testRoot = undefined; + await rm(root, { recursive: true, force: true }); +} + +async function makeSettings() { + testRoot = await mkdtemp(join(tmpdir(), "openclaw-debug-proxy-managed-proxy-")); + return { + enabled: true, + required: false, + dbPath: ":memory:", + blobDir: join(testRoot, "blobs"), + certDir: join(testRoot, "certs"), + sessionId: "debug-proxy-managed-proxy-test", + sourceProcess: "test", + }; +} + +async function connectThroughProxy(proxyUrl: string): Promise { + const target = new URL(proxyUrl); + 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(target.port), target.hostname, resolve); + }); + socket.write("CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n"); + await new Promise((resolve) => socket.once("end", resolve)); + socket.destroy(); + return data; +} + +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"]; + + beforeEach(async () => { + await cleanupTestDirs(); + delete process.env["OPENCLAW_PROXY_ACTIVE"]; + delete process.env["OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY"]; + }); + + afterEach(async () => { + if (originalProxyActive === undefined) { + delete process.env["OPENCLAW_PROXY_ACTIVE"]; + } else { + process.env["OPENCLAW_PROXY_ACTIVE"] = originalProxyActive; + } + if (originalAllowDirect === undefined) { + delete process.env["OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY"]; + } else { + process.env["OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY"] = + originalAllowDirect; + } + await cleanupTestDirs(); + }); + + it("allows direct upstreams when managed proxy mode is inactive", () => { + expect(() => assertDebugProxyDirectUpstreamAllowed()).not.toThrow(); + }); + + it("rejects direct upstreams while managed proxy mode is active", () => { + process.env["OPENCLAW_PROXY_ACTIVE"] = "1"; + + 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(() => assertDebugProxyDirectUpstreamAllowed()).toThrow( + /Debug proxy direct upstream forwarding is disabled/, + ); + }); + + 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(() => assertDebugProxyDirectUpstreamAllowed()).not.toThrow(); + }); + + it("rejects CONNECT upstreams before opening direct sockets while managed proxy mode is active", async () => { + process.env["OPENCLAW_PROXY_ACTIVE"] = "1"; + const server = await startDebugProxyServer({ settings: await makeSettings() }); + try { + const response = await connectThroughProxy(server.proxyUrl); + + expect(response).toContain("403 Forbidden"); + expect(response).toContain("Connection: close"); + 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 864db758235..3ea799b4710 100644 --- a/src/proxy-capture/proxy-server.ts +++ b/src/proxy-capture/proxy-server.ts @@ -8,6 +8,32 @@ import { ensureDebugProxyCa } from "./ca.js"; import type { DebugProxySettings } from "./env.js"; import { getDebugProxyCaptureStore } from "./store.sqlite.js"; +const TRUTHY_ENV = new Set(["1", "true", "yes", "on"]); +const DEBUG_PROXY_DIRECT_CONNECT_OVERRIDE = + "OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY"; + +function isTruthyEnvValue(value: string | undefined): boolean { + return TRUTHY_ENV.has((value ?? "").trim().toLowerCase()); +} + +function isManagedProxyActive(env: NodeJS.ProcessEnv = process.env): boolean { + return isTruthyEnvValue(env["OPENCLAW_PROXY_ACTIVE"]); +} + +function allowsDirectConnectWithManagedProxy(env: NodeJS.ProcessEnv = process.env): boolean { + return isTruthyEnvValue(env[DEBUG_PROXY_DIRECT_CONNECT_OVERRIDE]); +} + +export function assertDebugProxyDirectUpstreamAllowed(env: NodeJS.ProcessEnv = process.env): void { + if (!isManagedProxyActive(env) || allowsDirectConnectWithManagedProxy(env)) { + return; + } + throw new Error( + "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.`, + ); +} + type DebugProxyServerHandle = { proxyUrl: string; stop: () => Promise; @@ -73,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, @@ -187,6 +240,29 @@ export async function startDebugProxyServer(params: { path: req.url ?? "", headersJson: JSON.stringify(req.headers), }); + 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: "connect", + direction: "local", + kind: "error", + flowId, + host: hostname, + path: req.url ?? "", + errorText: message, + }); + const responseBody = `${message}\n`; + clientSocket.end( + `HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(responseBody)}\r\n\r\n${responseBody}`, + ); + return; + } const upstreamSocket = net.connect(port, hostname, () => { clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); if (head.length > 0) {