Skip to content

Commit 824e0be

Browse files
committed
feat: US-109 - Filter dangerous env vars from child process spawn
1 parent 2f6b26b commit 824e0be

4 files changed

Lines changed: 777 additions & 667 deletions

File tree

packages/secure-exec-node/src/bridge-setup.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,28 @@ import {
4545
} from "./isolate-bootstrap.js";
4646
import type { DriverDeps } from "./isolate-bootstrap.js";
4747

48+
// Env vars that could hijack child processes (library injection, node flags)
49+
const DANGEROUS_ENV_KEYS = new Set([
50+
"LD_PRELOAD",
51+
"LD_LIBRARY_PATH",
52+
"NODE_OPTIONS",
53+
"DYLD_INSERT_LIBRARIES",
54+
]);
55+
56+
/** Strip env vars that allow library injection or node flag smuggling. */
57+
function stripDangerousEnv(
58+
env: Record<string, string> | undefined,
59+
): Record<string, string> | undefined {
60+
if (!env) return env;
61+
const result: Record<string, string> = {};
62+
for (const [key, value] of Object.entries(env)) {
63+
if (!DANGEROUS_ENV_KEYS.has(key)) {
64+
result[key] = value;
65+
}
66+
}
67+
return result;
68+
}
69+
4870
type BridgeDeps = Pick<
4971
DriverDeps,
5072
| "filesystem"
@@ -459,7 +481,7 @@ export async function setupRequire(
459481

460482
const proc = executor.spawn(command, args, {
461483
cwd: options.cwd,
462-
env: options.env,
484+
env: stripDangerousEnv(options.env),
463485
onStdout: (data) => {
464486
getDispatchRef().applySync(
465487
undefined,
@@ -539,7 +561,7 @@ export async function setupRequire(
539561

540562
const proc = executor.spawn(command, args, {
541563
cwd: options.cwd,
542-
env: options.env,
564+
env: stripDangerousEnv(options.env),
543565
onStdout: (data) => {
544566
if (maxBufferExceeded) return;
545567
stdoutBytes += data.length;

packages/secure-exec/tests/runtime-driver/node/env-leakage.test.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { afterEach, describe, expect, it } from "vitest";
2-
import { allowAllEnv } from "../../../src/index.js";
2+
import { allowAllEnv, allowAllChildProcess } from "../../../src/index.js";
33
import { createTestNodeRuntime } from "../../test-utils.js";
4-
import type { NodeRuntime } from "../../../src/index.js";
4+
import type { NodeRuntime, CommandExecutor } from "../../../src/index.js";
5+
import type { SpawnedProcess } from "../../../src/types.js";
56

67
type CapturedConsoleEvent = {
78
channel: "stdout" | "stderr";
@@ -104,4 +105,79 @@ describe("runtime driver specific: node env leakage", () => {
104105
expect(parsed.home).toBe(process.env.HOME ?? "/root");
105106
expect(parsed.marker).toBe("env-positive-control");
106107
});
108+
109+
// ---------------------------------------------------------------
110+
// Dangerous env var filtering for child process spawn
111+
// ---------------------------------------------------------------
112+
113+
function createCapturingExecutor() {
114+
const calls: { command: string; args: string[]; env?: Record<string, string> }[] = [];
115+
const executor: CommandExecutor = {
116+
spawn(command, args, options): SpawnedProcess {
117+
calls.push({ command, args, env: options?.env });
118+
return {
119+
writeStdin: () => {},
120+
closeStdin: () => {},
121+
kill: () => {},
122+
wait: () => Promise.resolve(0),
123+
};
124+
},
125+
};
126+
return { executor, calls };
127+
}
128+
129+
it("strips LD_PRELOAD from child process spawn env", async () => {
130+
const { executor, calls } = createCapturingExecutor();
131+
proc = createTestNodeRuntime({
132+
permissions: { ...allowAllChildProcess },
133+
commandExecutor: executor,
134+
});
135+
await proc.run(`
136+
const cp = require('child_process');
137+
cp.spawnSync('echo', ['hi'], {
138+
env: { LD_PRELOAD: '/evil/lib.so', SAFE_VAR: 'ok' },
139+
});
140+
`);
141+
expect(calls.length).toBe(1);
142+
expect(calls[0].env).toBeDefined();
143+
expect(calls[0].env!.LD_PRELOAD).toBeUndefined();
144+
expect(calls[0].env!.SAFE_VAR).toBe("ok");
145+
});
146+
147+
it("strips NODE_OPTIONS from child process spawn env", async () => {
148+
const { executor, calls } = createCapturingExecutor();
149+
proc = createTestNodeRuntime({
150+
permissions: { ...allowAllChildProcess },
151+
commandExecutor: executor,
152+
});
153+
await proc.run(`
154+
const cp = require('child_process');
155+
cp.spawnSync('echo', ['hi'], {
156+
env: { NODE_OPTIONS: '--require=evil.js', PATH: '/usr/bin' },
157+
});
158+
`);
159+
expect(calls.length).toBe(1);
160+
expect(calls[0].env!.NODE_OPTIONS).toBeUndefined();
161+
expect(calls[0].env!.PATH).toBe("/usr/bin");
162+
});
163+
164+
it("normal env vars pass through child process spawn correctly", async () => {
165+
const { executor, calls } = createCapturingExecutor();
166+
proc = createTestNodeRuntime({
167+
permissions: { ...allowAllChildProcess },
168+
commandExecutor: executor,
169+
});
170+
await proc.run(`
171+
const cp = require('child_process');
172+
cp.spawnSync('echo', ['hi'], {
173+
env: { PATH: '/usr/bin', HOME: '/home/test', CUSTOM: 'value' },
174+
});
175+
`);
176+
expect(calls.length).toBe(1);
177+
expect(calls[0].env).toEqual({
178+
PATH: "/usr/bin",
179+
HOME: "/home/test",
180+
CUSTOM: "value",
181+
});
182+
});
107183
});

0 commit comments

Comments
 (0)