fix(lobster): prevent cwd symlink escape

Add additional security-focused tests for cwd/executable validation.
This commit is contained in:
Tyler Yust
2026-01-31 11:41:47 -08:00
parent b5d51ed07c
commit 049e813831
2 changed files with 442 additions and 0 deletions

View File

@@ -233,6 +233,426 @@ describe("lobster plugin tool", () => {
}
});
it("rejects invalid JSON envelope from lobster", async () => {
const { dir } = await writeFakeLobsterScript(
`process.stdout.write(JSON.stringify({ hello: "world" }));\n`,
"openclaw-lobster-plugin-bad-envelope-",
);
const originalPath = process.env.PATH;
process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`;
try {
const tool = createLobsterTool(fakeApi());
await expect(
tool.execute("call3b", {
action: "run",
pipeline: "noop",
}),
).rejects.toThrow(/invalid JSON envelope/);
} finally {
process.env.PATH = originalPath;
}
});
it("requires action", async () => {
const tool = createLobsterTool(fakeApi());
await expect(tool.execute("call-action-missing", {})).rejects.toThrow(/action required/);
await expect(tool.execute("call-action-empty", { action: " " })).rejects.toThrow(
/action required/,
);
});
it("rejects unknown action", async () => {
const tool = createLobsterTool(fakeApi());
await expect(tool.execute("call-action-unknown", { action: "nope" })).rejects.toThrow(
/Unknown action/,
);
});
it("validates run/resume parameters", async () => {
const tool = createLobsterTool(fakeApi());
await expect(tool.execute("call-run-missing-pipeline", { action: "run" })).rejects.toThrow(
/pipeline required/,
);
await expect(
tool.execute("call-run-empty-pipeline", { action: "run", pipeline: " " }),
).rejects.toThrow(/pipeline required/);
await expect(tool.execute("call-resume-missing-token", { action: "resume" })).rejects.toThrow(
/token required/,
);
await expect(
tool.execute("call-resume-empty-token", { action: "resume", token: " ", approve: true }),
).rejects.toThrow(/token required/);
await expect(
tool.execute("call-resume-missing-approve", { action: "resume", token: "t" }),
).rejects.toThrow(/approve required/);
await expect(
tool.execute("call-resume-non-boolean-approve", {
action: "resume",
token: "t",
approve: "yes",
}),
).rejects.toThrow(/approve required/);
});
it("rejects pluginConfig.lobsterPath when not absolute", async () => {
const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: "./lobster" } }));
await expect(
tool.execute("call-plugin-config-relative", {
action: "run",
pipeline: "noop",
}),
).rejects.toThrow(/absolute path/);
});
it("rejects pluginConfig.lobsterPath when it does not exist", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-missing-"));
const missingPath = path.join(dir, "lobster");
const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: missingPath } }));
await expect(
tool.execute("call-plugin-config-missing", {
action: "run",
pipeline: "noop",
}),
).rejects.toThrow(/must exist/);
});
it("rejects pluginConfig.lobsterPath when it points to a directory", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-dir-"));
const lobsterDir = path.join(dir, "lobster");
await fs.mkdir(lobsterDir);
const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: lobsterDir } }));
await expect(
tool.execute("call-plugin-config-dir", {
action: "run",
pipeline: "noop",
}),
).rejects.toThrow(/point to a file/);
});
it("rejects pluginConfig.lobsterPath when it is not executable (posix)", async () => {
if (process.platform === "win32") {
return;
}
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-nonexec-"));
const binPath = path.join(dir, "lobster");
await fs.writeFile(binPath, "#!/usr/bin/env node\nprocess.stdout.write('[]')\n", {
encoding: "utf8",
mode: 0o644,
});
const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: binPath } }));
await expect(
tool.execute("call-plugin-config-nonexec", {
action: "run",
pipeline: "noop",
}),
).rejects.toThrow(/executable/);
});
it("trims pluginConfig.lobsterPath", async () => {
const fake = await writeFakeLobster({
payload: { ok: true, status: "ok", output: [], requiresApproval: null },
});
// Ensure `lobster` is NOT discoverable via PATH, while still allowing our
// fake lobster (a Node script with `#!/usr/bin/env node`) to run.
const originalPath = process.env.PATH;
process.env.PATH = path.dirname(process.execPath);
try {
const tool = createLobsterTool(
fakeApi({ pluginConfig: { lobsterPath: ` ${fake.binPath} ` } }),
);
const res = await tool.execute("call-plugin-config-trim", {
action: "run",
pipeline: "noop",
timeoutMs: 1000,
});
expect(res.details).toMatchObject({ ok: true, status: "ok" });
} finally {
process.env.PATH = originalPath;
}
});
it("ignores non-string pluginConfig.lobsterPath", async () => {
const fake = await writeFakeLobster({
payload: { ok: true, status: "ok", output: [], requiresApproval: null },
});
const originalPath = process.env.PATH;
process.env.PATH = `${fake.dir}${path.delimiter}${originalPath ?? ""}`;
try {
const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: 123 as any } }));
const res = await tool.execute("call-plugin-config-non-string", {
action: "run",
pipeline: "noop",
timeoutMs: 1000,
});
expect(res.details).toMatchObject({ ok: true, status: "ok" });
} finally {
process.env.PATH = originalPath;
}
});
it("validates deprecated lobsterPath even though it is ignored", async () => {
const fake = await writeFakeLobster({
payload: { ok: true, status: "ok", output: [], requiresApproval: null },
});
// Ensure `lobster` is NOT discoverable via PATH, while still allowing our
// fake lobster to run via plugin config.
const originalPath = process.env.PATH;
process.env.PATH = path.dirname(process.execPath);
try {
const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: fake.binPath } }));
await expect(
tool.execute("call-deprecated-invalid-with-plugin-config", {
action: "run",
pipeline: "noop",
lobsterPath: "/bin/bash",
timeoutMs: 1000,
}),
).rejects.toThrow(/lobster executable/);
} finally {
process.env.PATH = originalPath;
}
});
it("rejects lobsterPath injection attempts", async () => {
const tool = createLobsterTool(fakeApi());
await expect(
tool.execute("call-lobsterpath-injection", {
action: "run",
pipeline: "noop",
lobsterPath: "/tmp/lobster --help",
}),
).rejects.toThrow(/lobster executable/);
});
it("defaults cwd when empty or non-string", async () => {
const payload = {
ok: true,
status: "ok",
output: [{ cwd: "__REPLACED__" }],
requiresApproval: null,
};
const { dir } = await writeFakeLobsterScript(
`const payload = ${JSON.stringify(payload)};\n` +
`payload.output[0].cwd = process.cwd();\n` +
`process.stdout.write(JSON.stringify(payload));\n`,
"openclaw-lobster-plugin-cwd-default-",
);
const originalPath = process.env.PATH;
process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`;
try {
const tool = createLobsterTool(fakeApi());
const res1 = await tool.execute("call-cwd-empty", {
action: "run",
pipeline: "noop",
cwd: " ",
timeoutMs: 1000,
});
expect((res1.details as any).output[0].cwd).toBe(process.cwd());
const res2 = await tool.execute("call-cwd-non-string", {
action: "run",
pipeline: "noop",
cwd: 123,
timeoutMs: 1000,
});
expect((res2.details as any).output[0].cwd).toBe(process.cwd());
} finally {
process.env.PATH = originalPath;
}
});
it("uses trimmed relative cwd within the gateway working directory", async () => {
const relDir = `.vitest-lobster-cwd-${Date.now()}`;
const absDir = path.join(process.cwd(), relDir);
await fs.mkdir(absDir);
const payload = {
ok: true,
status: "ok",
output: [{ cwd: "__REPLACED__" }],
requiresApproval: null,
};
const { dir } = await writeFakeLobsterScript(
`const payload = ${JSON.stringify(payload)};\n` +
`payload.output[0].cwd = process.cwd();\n` +
`process.stdout.write(JSON.stringify(payload));\n`,
"openclaw-lobster-plugin-cwd-allowed-",
);
const originalPath = process.env.PATH;
process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`;
try {
const tool = createLobsterTool(fakeApi());
const res = await tool.execute("call-cwd-trim", {
action: "run",
pipeline: "noop",
cwd: ` ${relDir} `,
timeoutMs: 1000,
});
expect((res.details as any).output[0].cwd).toBe(absDir);
} finally {
process.env.PATH = originalPath;
await fs.rm(absDir, { recursive: true, force: true });
}
});
it("rejects cwd that escapes via symlink", async () => {
if (process.platform === "win32") {
// Windows symlink creation can require elevated privileges in CI.
return;
}
const outside = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-outside-"));
const linkName = `.vitest-lobster-symlink-${Date.now()}`;
const linkPath = path.join(process.cwd(), linkName);
await fs.symlink(outside, linkPath, "dir");
try {
const tool = createLobsterTool(fakeApi());
await expect(
tool.execute("call-cwd-symlink-escape", {
action: "run",
pipeline: "noop",
cwd: linkName,
}),
).rejects.toThrow(/must stay within/);
} finally {
await fs.rm(linkPath, { recursive: true, force: true });
await fs.rm(outside, { recursive: true, force: true });
}
});
it("enforces maxStdoutBytes", async () => {
const { dir } = await writeFakeLobsterScript(
`process.stdout.write("x".repeat(20_000));\n`,
"openclaw-lobster-plugin-stdout-limit-",
);
const originalPath = process.env.PATH;
process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`;
try {
const tool = createLobsterTool(fakeApi());
await expect(
tool.execute("call-stdout-limit", {
action: "run",
pipeline: "noop",
timeoutMs: 2000,
maxStdoutBytes: 1024,
}),
).rejects.toThrow(/maxStdoutBytes/);
} finally {
process.env.PATH = originalPath;
}
});
it("times out lobster subprocess", async () => {
const { dir } = await writeFakeLobsterScript(
`setTimeout(() => {}, 10_000);\n`,
"openclaw-lobster-plugin-timeout-",
);
const originalPath = process.env.PATH;
process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`;
try {
const tool = createLobsterTool(fakeApi());
await expect(
tool.execute("call-timeout", {
action: "run",
pipeline: "noop",
timeoutMs: 250,
}),
).rejects.toThrow(/timed out/);
} finally {
process.env.PATH = originalPath;
}
});
it("removes NODE_OPTIONS containing --inspect from child env", async () => {
const payload = {
ok: true,
status: "ok",
output: [{ nodeOptions: "__REPLACED__" }],
requiresApproval: null,
};
const { dir } = await writeFakeLobsterScript(
`const payload = ${JSON.stringify(payload)};\n` +
`payload.output[0].nodeOptions = process.env.NODE_OPTIONS ?? null;\n` +
`process.stdout.write(JSON.stringify(payload));\n`,
"openclaw-lobster-plugin-node-options-",
);
const originalPath = process.env.PATH;
const originalNodeOptions = process.env.NODE_OPTIONS;
process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`;
process.env.NODE_OPTIONS = "--inspect=0";
try {
const tool = createLobsterTool(fakeApi());
const res = await tool.execute("call-node-options", {
action: "run",
pipeline: "noop",
timeoutMs: 1000,
});
expect((res.details as any).output[0].nodeOptions).toBeNull();
} finally {
process.env.PATH = originalPath;
process.env.NODE_OPTIONS = originalNodeOptions;
}
});
it("runs on Windows when lobster is only available as lobster.cmd on PATH (shell fallback)", async () => {
if (process.platform !== "win32") {
return;
}
const fake = await writeFakeLobster({
payload: { ok: true, status: "ok", output: [{ hello: "win" }], requiresApproval: null },
});
const originalPath = process.env.PATH;
process.env.PATH = `${fake.dir}${path.delimiter}${originalPath ?? ""}`;
try {
const tool = createLobsterTool(fakeApi());
const res = await tool.execute("call-win-shell-fallback", {
action: "run",
pipeline: "noop",
timeoutMs: 2000,
});
expect(res.details).toMatchObject({ ok: true, status: "ok" });
} finally {
process.env.PATH = originalPath;
}
});
it("can be gated off in sandboxed contexts", async () => {
const api = fakeApi();
const factoryTool = (ctx: OpenClawPluginToolContext) => {

View File

@@ -82,6 +82,28 @@ function resolveCwd(cwdRaw: unknown): string {
if (rel.startsWith("..") || path.isAbsolute(rel)) {
throw new Error("cwd must stay within the gateway working directory");
}
// SECURITY: prevent escapes via symlinks that point outside the base dir.
// If the path exists, compare its realpath with the base realpath.
if (fs.existsSync(resolved)) {
let baseReal: string;
let resolvedReal: string;
try {
baseReal = fs.realpathSync(base);
resolvedReal = fs.realpathSync(resolved);
} catch {
throw new Error("cwd must stay within the gateway working directory");
}
const relReal = path.relative(
normalizeForCwdSandbox(baseReal),
normalizeForCwdSandbox(resolvedReal),
);
if (relReal.startsWith("..") || path.isAbsolute(relReal)) {
throw new Error("cwd must stay within the gateway working directory");
}
}
return resolved;
}