Skip to content

Commit f42a2c7

Browse files
fix: guard debug proxy CONNECT under managed proxy (#77010)
Summary: - The PR adds a managed-proxy-aware debug proxy direct-upstream guard, a diagnostics override env var, regression tests, docs, and a changelog entry. - Reproducibility: yes. Source inspection on current main shows direct HTTP forwarding and CONNECT net.connect() can run while managed proxy mode is active, against the documented managed-proxy egress guardrail. Automerge notes: - Ran the ClawSweeper repair loop before final review. - Included post-review commit in the final squash: fix(clawsweeper): address review for automerge-openclaw-openclaw-7701… Validation: - ClawSweeper review passed for head aaa52a7. - Required merge gates passed before the squash merge. Prepared head SHA: aaa52a7 Review: #77010 (comment) Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
1 parent 973e240 commit f42a2c7

5 files changed

Lines changed: 266 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ Docs: https://docs.openclaw.ai
167167
- 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)
168168
- 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.
169169
- 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.
170+
- 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.
170171
- 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.
171172
- 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.
172173
- 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.

docs/cli/proxy.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ semantics.
6868

6969
- `start` defaults to `127.0.0.1` unless `--host` is set.
7070
- `run` starts a local debug proxy and then runs the command after `--`.
71+
- 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.
7172
- `validate` exits with code 1 when proxy config or destination checks fail.
7273
- Captures are local debugging data; use `openclaw proxy purge` when finished.
7374

docs/security/network-proxy.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ proxy:
194194
- The proxy improves coverage for process-local JavaScript HTTP and WebSocket clients, but it is not an OS-level network sandbox.
195195
- 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.
196196
- 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.
197+
- 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.
197198
- 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.
198199
- 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.
199200
- OpenClaw does not inspect, test, or certify your proxy policy.
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { mkdtemp, rm } from "node:fs/promises";
2+
import { createServer as createHttpServer } from "node:http";
3+
import { Socket, type AddressInfo } from "node:net";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
7+
import { assertDebugProxyDirectUpstreamAllowed, startDebugProxyServer } from "./proxy-server.js";
8+
9+
let testRoot: string | undefined;
10+
11+
async function cleanupTestDirs(): Promise<void> {
12+
if (!testRoot) {
13+
return;
14+
}
15+
const root = testRoot;
16+
testRoot = undefined;
17+
await rm(root, { recursive: true, force: true });
18+
}
19+
20+
async function makeSettings() {
21+
testRoot = await mkdtemp(join(tmpdir(), "openclaw-debug-proxy-managed-proxy-"));
22+
return {
23+
enabled: true,
24+
required: false,
25+
dbPath: ":memory:",
26+
blobDir: join(testRoot, "blobs"),
27+
certDir: join(testRoot, "certs"),
28+
sessionId: "debug-proxy-managed-proxy-test",
29+
sourceProcess: "test",
30+
};
31+
}
32+
33+
async function connectThroughProxy(proxyUrl: string): Promise<string> {
34+
const target = new URL(proxyUrl);
35+
const socket = new Socket();
36+
let data = "";
37+
socket.setEncoding("utf8");
38+
socket.on("data", (chunk) => {
39+
data += chunk;
40+
});
41+
await new Promise<void>((resolve, reject) => {
42+
socket.once("error", reject);
43+
socket.connect(Number(target.port), target.hostname, resolve);
44+
});
45+
socket.write("CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n");
46+
await new Promise<void>((resolve) => socket.once("end", resolve));
47+
socket.destroy();
48+
return data;
49+
}
50+
51+
async function requestThroughProxy(proxyUrl: string, targetUrl: string): Promise<string> {
52+
const proxy = new URL(proxyUrl);
53+
const target = new URL(targetUrl);
54+
const socket = new Socket();
55+
let data = "";
56+
socket.setEncoding("utf8");
57+
socket.on("data", (chunk) => {
58+
data += chunk;
59+
});
60+
await new Promise<void>((resolve, reject) => {
61+
socket.once("error", reject);
62+
socket.connect(Number(proxy.port), proxy.hostname, resolve);
63+
});
64+
socket.write(`GET ${target.href} HTTP/1.1\r\nHost: ${target.host}\r\nConnection: close\r\n\r\n`);
65+
await new Promise<void>((resolve) => socket.once("end", resolve));
66+
socket.destroy();
67+
return data;
68+
}
69+
70+
async function startCanaryOrigin(): Promise<{
71+
requestCount: () => number;
72+
stop: () => Promise<void>;
73+
url: string;
74+
}> {
75+
let requests = 0;
76+
const server = createHttpServer((_req, res) => {
77+
requests += 1;
78+
res.end("ok");
79+
});
80+
await new Promise<void>((resolve, reject) => {
81+
server.once("error", reject);
82+
server.listen(0, "127.0.0.1", () => {
83+
server.off("error", reject);
84+
resolve();
85+
});
86+
});
87+
const address = server.address() as AddressInfo;
88+
return {
89+
requestCount: () => requests,
90+
stop: async () =>
91+
await new Promise<void>((resolve, reject) => {
92+
server.close((error) => {
93+
if (error) {
94+
reject(error);
95+
return;
96+
}
97+
resolve();
98+
});
99+
}),
100+
url: `http://127.0.0.1:${address.port}/metadata`,
101+
};
102+
}
103+
104+
describe("debug proxy managed-proxy direct upstream policy", () => {
105+
const originalProxyActive = process.env["OPENCLAW_PROXY_ACTIVE"];
106+
const originalAllowDirect =
107+
process.env["OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY"];
108+
109+
beforeEach(async () => {
110+
await cleanupTestDirs();
111+
delete process.env["OPENCLAW_PROXY_ACTIVE"];
112+
delete process.env["OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY"];
113+
});
114+
115+
afterEach(async () => {
116+
if (originalProxyActive === undefined) {
117+
delete process.env["OPENCLAW_PROXY_ACTIVE"];
118+
} else {
119+
process.env["OPENCLAW_PROXY_ACTIVE"] = originalProxyActive;
120+
}
121+
if (originalAllowDirect === undefined) {
122+
delete process.env["OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY"];
123+
} else {
124+
process.env["OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY"] =
125+
originalAllowDirect;
126+
}
127+
await cleanupTestDirs();
128+
});
129+
130+
it("allows direct upstreams when managed proxy mode is inactive", () => {
131+
expect(() => assertDebugProxyDirectUpstreamAllowed()).not.toThrow();
132+
});
133+
134+
it("rejects direct upstreams while managed proxy mode is active", () => {
135+
process.env["OPENCLAW_PROXY_ACTIVE"] = "1";
136+
137+
expect(() => assertDebugProxyDirectUpstreamAllowed()).toThrow(
138+
/Debug proxy direct upstream forwarding is disabled/,
139+
);
140+
});
141+
142+
it("uses shared truthy parsing for managed proxy mode", () => {
143+
process.env["OPENCLAW_PROXY_ACTIVE"] = "true";
144+
145+
expect(() => assertDebugProxyDirectUpstreamAllowed()).toThrow(
146+
/Debug proxy direct upstream forwarding is disabled/,
147+
);
148+
});
149+
150+
it("allows direct upstreams with explicit diagnostic override", () => {
151+
process.env["OPENCLAW_PROXY_ACTIVE"] = "1";
152+
process.env["OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY"] = "1";
153+
154+
expect(() => assertDebugProxyDirectUpstreamAllowed()).not.toThrow();
155+
});
156+
157+
it("rejects CONNECT upstreams before opening direct sockets while managed proxy mode is active", async () => {
158+
process.env["OPENCLAW_PROXY_ACTIVE"] = "1";
159+
const server = await startDebugProxyServer({ settings: await makeSettings() });
160+
try {
161+
const response = await connectThroughProxy(server.proxyUrl);
162+
163+
expect(response).toContain("403 Forbidden");
164+
expect(response).toContain("Connection: close");
165+
expect(response).toContain("Debug proxy direct upstream forwarding is disabled");
166+
} finally {
167+
await server.stop();
168+
}
169+
});
170+
171+
it("rejects absolute-form HTTP proxy requests before opening direct upstreams while managed proxy mode is active", async () => {
172+
process.env["OPENCLAW_PROXY_ACTIVE"] = "1";
173+
const origin = await startCanaryOrigin();
174+
const server = await startDebugProxyServer({ settings: await makeSettings() });
175+
try {
176+
const response = await requestThroughProxy(server.proxyUrl, origin.url);
177+
178+
expect(response).toContain("403 Forbidden");
179+
expect(response).toContain("Connection: close");
180+
expect(response).toContain("Debug proxy direct upstream forwarding is disabled");
181+
expect(origin.requestCount()).toBe(0);
182+
} finally {
183+
await server.stop();
184+
await origin.stop();
185+
}
186+
});
187+
});

src/proxy-capture/proxy-server.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,32 @@ import { ensureDebugProxyCa } from "./ca.js";
88
import type { DebugProxySettings } from "./env.js";
99
import { getDebugProxyCaptureStore } from "./store.sqlite.js";
1010

11+
const TRUTHY_ENV = new Set(["1", "true", "yes", "on"]);
12+
const DEBUG_PROXY_DIRECT_CONNECT_OVERRIDE =
13+
"OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY";
14+
15+
function isTruthyEnvValue(value: string | undefined): boolean {
16+
return TRUTHY_ENV.has((value ?? "").trim().toLowerCase());
17+
}
18+
19+
function isManagedProxyActive(env: NodeJS.ProcessEnv = process.env): boolean {
20+
return isTruthyEnvValue(env["OPENCLAW_PROXY_ACTIVE"]);
21+
}
22+
23+
function allowsDirectConnectWithManagedProxy(env: NodeJS.ProcessEnv = process.env): boolean {
24+
return isTruthyEnvValue(env[DEBUG_PROXY_DIRECT_CONNECT_OVERRIDE]);
25+
}
26+
27+
export function assertDebugProxyDirectUpstreamAllowed(env: NodeJS.ProcessEnv = process.env): void {
28+
if (!isManagedProxyActive(env) || allowsDirectConnectWithManagedProxy(env)) {
29+
return;
30+
}
31+
throw new Error(
32+
"Debug proxy direct upstream forwarding is disabled while managed proxy mode is active. " +
33+
`Set ${DEBUG_PROXY_DIRECT_CONNECT_OVERRIDE}=1 only for approved local diagnostics.`,
34+
);
35+
}
36+
1137
type DebugProxyServerHandle = {
1238
proxyUrl: string;
1339
stop: () => Promise<void>;
@@ -73,6 +99,33 @@ export async function startDebugProxyServer(params: {
7399
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
74100
const flowId = randomUUID();
75101
const target = normalizeTargetUrl(req);
102+
try {
103+
assertDebugProxyDirectUpstreamAllowed();
104+
} catch (error) {
105+
const message = error instanceof Error ? error.message : String(error);
106+
store.recordEvent({
107+
sessionId: params.settings.sessionId,
108+
ts: Date.now(),
109+
sourceScope: "openclaw",
110+
sourceProcess: params.settings.sourceProcess,
111+
protocol: target.protocol === "https:" ? "https" : "http",
112+
direction: "local",
113+
kind: "error",
114+
flowId,
115+
method: req.method,
116+
host: target.host,
117+
path: `${target.pathname}${target.search}`,
118+
errorText: message,
119+
});
120+
const responseBody = `${message}\n`;
121+
res.writeHead(403, {
122+
Connection: "close",
123+
"Content-Type": "text/plain; charset=utf-8",
124+
"Content-Length": Buffer.byteLength(responseBody),
125+
});
126+
res.end(responseBody);
127+
return;
128+
}
76129
const body = await readBody(req);
77130
store.recordEvent({
78131
sessionId: params.settings.sessionId,
@@ -187,6 +240,29 @@ export async function startDebugProxyServer(params: {
187240
path: req.url ?? "",
188241
headersJson: JSON.stringify(req.headers),
189242
});
243+
try {
244+
assertDebugProxyDirectUpstreamAllowed();
245+
} catch (error) {
246+
const message = error instanceof Error ? error.message : String(error);
247+
store.recordEvent({
248+
sessionId: params.settings.sessionId,
249+
ts: Date.now(),
250+
sourceScope: "openclaw",
251+
sourceProcess: params.settings.sourceProcess,
252+
protocol: "connect",
253+
direction: "local",
254+
kind: "error",
255+
flowId,
256+
host: hostname,
257+
path: req.url ?? "",
258+
errorText: message,
259+
});
260+
const responseBody = `${message}\n`;
261+
clientSocket.end(
262+
`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}`,
263+
);
264+
return;
265+
}
190266
const upstreamSocket = net.connect(port, hostname, () => {
191267
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
192268
if (head.length > 0) {

0 commit comments

Comments
 (0)