Skip to content

Commit 469c26c

Browse files
committed
fix(codex): align sandbox http policy
1 parent 1e64469 commit 469c26c

2 files changed

Lines changed: 112 additions & 2 deletions

File tree

extensions/codex/src/app-server/sandbox-exec-server.http.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Codex tests cover sandbox exec server.http plugin behavior.
2+
import { spawn } from "node:child_process";
23
import { afterEach, describe, expect, it, vi } from "vitest";
34
import {
45
closeCodexSandboxExecServersForTests,
@@ -13,7 +14,10 @@ import {
1314
rpc,
1415
waitForHttpBodyDeltas,
1516
} from "./sandbox-exec-server.test-helpers.js";
16-
import { SANDBOX_HTTP_STREAM_LINE_MAX_CHARS } from "./sandbox-exec-server/http.js";
17+
import {
18+
SANDBOX_HTTP_REQUEST_SCRIPT,
19+
SANDBOX_HTTP_STREAM_LINE_MAX_CHARS,
20+
} from "./sandbox-exec-server/http.js";
1721

1822
afterEach(async () => {
1923
vi.unstubAllEnvs();
@@ -26,6 +30,32 @@ function testExecEnv(): NodeJS.ProcessEnv {
2630
};
2731
}
2832

33+
function runSandboxHttpRequestScript(input: unknown): Promise<{
34+
code: number | null;
35+
stderr: string;
36+
stdout: string;
37+
}> {
38+
return new Promise((resolve, reject) => {
39+
const child = spawn("bash", ["-lc", SANDBOX_HTTP_REQUEST_SCRIPT], {
40+
env: testExecEnv(),
41+
stdio: ["pipe", "pipe", "pipe"],
42+
});
43+
let stdout = "";
44+
let stderr = "";
45+
child.stdout.on("data", (chunk: Buffer) => {
46+
stdout += chunk.toString("utf8");
47+
});
48+
child.stderr.on("data", (chunk: Buffer) => {
49+
stderr += chunk.toString("utf8");
50+
});
51+
child.once("error", reject);
52+
child.once("close", (code) => {
53+
resolve({ code, stderr, stdout });
54+
});
55+
child.stdin.end(JSON.stringify(input));
56+
});
57+
}
58+
2959
describe("OpenClaw Codex sandbox exec-server HTTP", () => {
3060
it("routes HTTP requests through the sandbox backend", async () => {
3161
const runShellCommand = vi.fn(async () => ({
@@ -126,6 +156,30 @@ describe("OpenClaw Codex sandbox exec-server HTTP", () => {
126156
socket.close();
127157
});
128158

159+
it("blocks protected IP classes inside the sandbox Python helper", async () => {
160+
const blockedUrls = [
161+
"http://100.100.100.200/",
162+
"http://[fd00:ec2::254]/",
163+
"http://[fec0::1]/",
164+
"http://[64:ff9b::100.100.100.200]/",
165+
"http://[64:ff9b:1::6464:64c8]/",
166+
"http://[2002:6464:64c8::]/",
167+
"http://[2001::9b9b:9b37]/",
168+
"http://[2001:4860:1::5efe:6464:64c8]/",
169+
];
170+
171+
for (const url of blockedUrls) {
172+
const result = await runSandboxHttpRequestScript({
173+
method: "GET",
174+
url,
175+
timeoutMs: 1,
176+
});
177+
expect(result.code, url).not.toBe(0);
178+
expect(result.stdout, url).toBe("");
179+
expect(result.stderr, url).toContain("Blocked");
180+
}
181+
});
182+
129183
it("streams HTTP response body deltas from the sandbox backend", async () => {
130184
const headerLine = JSON.stringify({
131185
type: "headers",

extensions/codex/src/app-server/sandbox-exec-server/http.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ function readStreamingSandboxHttpResponse(params: {
252252
});
253253
}
254254

255-
const SANDBOX_HTTP_REQUEST_SCRIPT = String.raw`
255+
export const SANDBOX_HTTP_REQUEST_SCRIPT = String.raw`
256256
tmp=$(mktemp "$TMPDIR/openclaw-http.XXXXXX.py" 2>/dev/null || mktemp "/tmp/openclaw-http.XXXXXX.py") || exit 1
257257
trap 'rm -f "$tmp"' EXIT
258258
cat > "$tmp" <<'PY'
@@ -276,6 +276,27 @@ BLOCKED_HOSTNAMES = {
276276
"localhost.localdomain",
277277
"metadata.google.internal",
278278
}
279+
CLOUD_METADATA_IP_ADDRESSES = {
280+
"100.100.100.200",
281+
"fd00:ec2::254",
282+
}
283+
BLOCKED_IPV4_NETWORKS = tuple(
284+
ipaddress.ip_network(network)
285+
for network in (
286+
"100.64.0.0/10",
287+
"198.18.0.0/15",
288+
)
289+
)
290+
BLOCKED_IPV6_NETWORKS = tuple(
291+
ipaddress.ip_network(network)
292+
for network in (
293+
"100::/64",
294+
"2001:2::/48",
295+
"2001:20::/28",
296+
"2001:db8::/32",
297+
"fec0::/10",
298+
)
299+
)
279300
PINNED_ADDRESSES = {}
280301
281302
def normalize_hostname(hostname):
@@ -295,6 +316,17 @@ def is_blocked_ip(address):
295316
parsed = ipaddress.ip_address(address)
296317
except ValueError:
297318
return False
319+
embedded_ipv4 = extract_embedded_ipv4(parsed)
320+
if embedded_ipv4 is not None and is_blocked_ip(str(embedded_ipv4)):
321+
return True
322+
if str(parsed).lower() in CLOUD_METADATA_IP_ADDRESSES:
323+
return True
324+
if isinstance(parsed, ipaddress.IPv4Address):
325+
if any(parsed in network for network in BLOCKED_IPV4_NETWORKS):
326+
return True
327+
else:
328+
if any(parsed in network for network in BLOCKED_IPV6_NETWORKS):
329+
return True
298330
return (
299331
parsed.is_loopback
300332
or parsed.is_private
@@ -304,6 +336,30 @@ def is_blocked_ip(address):
304336
or parsed.is_unspecified
305337
)
306338
339+
def ipv4_from_int(value):
340+
return ipaddress.IPv4Address(value & 0xffffffff)
341+
342+
def extract_embedded_ipv4(address):
343+
if not isinstance(address, ipaddress.IPv6Address):
344+
return None
345+
if address.ipv4_mapped is not None:
346+
return address.ipv4_mapped
347+
value = int(address)
348+
hextets = [(value >> shift) & 0xffff for shift in range(112, -1, -16)]
349+
if hextets[:6] == [0, 0, 0, 0, 0, 0]:
350+
return ipv4_from_int(value)
351+
if hextets[:6] == [0x64, 0xff9b, 0, 0, 0, 0]:
352+
return ipv4_from_int(value)
353+
if hextets[:6] == [0x64, 0xff9b, 1, 0, 0, 0]:
354+
return ipv4_from_int(value)
355+
if hextets[0] == 0x2002:
356+
return ipv4_from_int((hextets[1] << 16) | hextets[2])
357+
if hextets[0] == 0x2001 and hextets[1] == 0:
358+
return ipv4_from_int(((hextets[6] << 16) | hextets[7]) ^ 0xffffffff)
359+
if (hextets[4] & 0xfcff) == 0 and hextets[5] == 0x5efe:
360+
return ipv4_from_int((hextets[6] << 16) | hextets[7])
361+
return None
362+
307363
def assert_url_allowed(url):
308364
parsed = urllib.parse.urlparse(url)
309365
if parsed.scheme not in ("http", "https"):

0 commit comments

Comments
 (0)