fix: guard debug proxy CONNECT under managed proxy

This commit is contained in:
jesse-merhi
2026-05-04 10:40:13 +10:00
committed by clawsweeper
parent e3cba91ef0
commit 878f5a57d0
5 changed files with 168 additions and 0 deletions

View File

@@ -166,6 +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 <plugin>` 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.
- 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.

View File

@@ -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 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.
- `validate` exits with code 1 when proxy config or destination checks fail.
- Captures are local debugging data; use `openclaw proxy purge` when finished.

View File

@@ -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 CONNECT upstream forwarding is disabled by default while managed proxy mode is active; enable direct CONNECT 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.

View File

@@ -0,0 +1,116 @@
import { mkdtemp, rm } from "node:fs/promises";
import { Socket } 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";
let testRoot: string | undefined;
async function cleanupTestDirs(): Promise<void> {
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<string> {
const target = new URL(proxyUrl);
const socket = new Socket();
let data = "";
socket.setEncoding("utf8");
socket.on("data", (chunk) => {
data += chunk;
});
await new Promise<void>((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<void>((resolve) => socket.once("end", resolve));
socket.destroy();
return data;
}
describe("debug proxy managed-proxy CONNECT 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 CONNECT upstreams when managed proxy mode is inactive", () => {
expect(() => assertDebugProxyDirectConnectAllowed()).not.toThrow();
});
it("rejects direct CONNECT upstreams while managed proxy mode is active", () => {
process.env["OPENCLAW_PROXY_ACTIVE"] = "1";
expect(() => assertDebugProxyDirectConnectAllowed()).toThrow(
/Debug proxy CONNECT 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/,
);
});
it("allows direct CONNECT 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();
});
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 CONNECT upstream forwarding is disabled");
} finally {
await server.stop();
}
});
});

View File

@@ -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 assertDebugProxyDirectConnectAllowed(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. " +
`Set ${DEBUG_PROXY_DIRECT_CONNECT_OVERRIDE}=1 only for approved local diagnostics.`,
);
}
type DebugProxyServerHandle = {
proxyUrl: string;
stop: () => Promise<void>;
@@ -187,6 +213,29 @@ export async function startDebugProxyServer(params: {
path: req.url ?? "",
headersJson: JSON.stringify(req.headers),
});
try {
assertDebugProxyDirectConnectAllowed();
} 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) {