mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 13:21:25 +00:00
fix(gateway): block agents.files symlink escapes
This commit is contained in:
@@ -26,7 +26,10 @@ const mocks = vi.hoisted(() => ({
|
||||
fsMkdir: vi.fn(async () => undefined),
|
||||
fsAppendFile: vi.fn(async () => {}),
|
||||
fsReadFile: vi.fn(async () => ""),
|
||||
fsStat: vi.fn(async () => null),
|
||||
fsStat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null),
|
||||
fsLstat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null),
|
||||
fsRealpath: vi.fn(async (p: string) => p),
|
||||
fsOpen: vi.fn(async () => ({}) as unknown),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
@@ -85,6 +88,9 @@ vi.mock("node:fs/promises", async () => {
|
||||
appendFile: mocks.fsAppendFile,
|
||||
readFile: mocks.fsReadFile,
|
||||
stat: mocks.fsStat,
|
||||
lstat: mocks.fsLstat,
|
||||
realpath: mocks.fsRealpath,
|
||||
open: mocks.fsOpen,
|
||||
};
|
||||
return { ...patched, default: patched };
|
||||
});
|
||||
@@ -125,6 +131,33 @@ function createErrnoError(code: string) {
|
||||
return err;
|
||||
}
|
||||
|
||||
function makeFileStat(params?: {
|
||||
size?: number;
|
||||
mtimeMs?: number;
|
||||
dev?: number;
|
||||
ino?: number;
|
||||
}): import("node:fs").Stats {
|
||||
return {
|
||||
isFile: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
size: params?.size ?? 10,
|
||||
mtimeMs: params?.mtimeMs ?? 1234,
|
||||
dev: params?.dev ?? 1,
|
||||
ino: params?.ino ?? 1,
|
||||
} as unknown as import("node:fs").Stats;
|
||||
}
|
||||
|
||||
function makeSymlinkStat(params?: { dev?: number; ino?: number }): import("node:fs").Stats {
|
||||
return {
|
||||
isFile: () => false,
|
||||
isSymbolicLink: () => true,
|
||||
size: 0,
|
||||
mtimeMs: 0,
|
||||
dev: params?.dev ?? 1,
|
||||
ino: params?.ino ?? 2,
|
||||
} as unknown as import("node:fs").Stats;
|
||||
}
|
||||
|
||||
function mockWorkspaceStateRead(params: {
|
||||
onboardingCompletedAt?: string;
|
||||
errorCode?: string;
|
||||
@@ -172,6 +205,19 @@ beforeEach(() => {
|
||||
mocks.fsStat.mockImplementation(async () => {
|
||||
throw createEnoentError();
|
||||
});
|
||||
mocks.fsLstat.mockImplementation(async () => {
|
||||
throw createEnoentError();
|
||||
});
|
||||
mocks.fsRealpath.mockImplementation(async (p: string) => p);
|
||||
mocks.fsOpen.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
stat: async () => makeFileStat(),
|
||||
readFile: async () => Buffer.from(""),
|
||||
writeFile: async () => {},
|
||||
close: async () => {},
|
||||
}) as unknown,
|
||||
);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -459,3 +505,147 @@ describe("agents.files.list", () => {
|
||||
expect(names).toContain("BOOTSTRAP.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("agents.files.get/set symlink safety", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.loadConfigReturn = {};
|
||||
mocks.fsMkdir.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("rejects agents.files.get when allowlisted file symlink escapes workspace", async () => {
|
||||
const workspace = "/workspace/test-agent";
|
||||
const candidate = `${workspace}/AGENTS.md`;
|
||||
mocks.fsRealpath.mockImplementation(async (p: string) => {
|
||||
if (p === workspace) {
|
||||
return workspace;
|
||||
}
|
||||
if (p === candidate) {
|
||||
return "/outside/secret.txt";
|
||||
}
|
||||
return p;
|
||||
});
|
||||
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
|
||||
const p = typeof args[0] === "string" ? args[0] : "";
|
||||
if (p === candidate) {
|
||||
return makeSymlinkStat();
|
||||
}
|
||||
throw createEnoentError();
|
||||
});
|
||||
|
||||
const { respond, promise } = makeCall("agents.files.get", {
|
||||
agentId: "main",
|
||||
name: "AGENTS.md",
|
||||
});
|
||||
await promise;
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects agents.files.set when allowlisted file symlink escapes workspace", async () => {
|
||||
const workspace = "/workspace/test-agent";
|
||||
const candidate = `${workspace}/AGENTS.md`;
|
||||
mocks.fsRealpath.mockImplementation(async (p: string) => {
|
||||
if (p === workspace) {
|
||||
return workspace;
|
||||
}
|
||||
if (p === candidate) {
|
||||
return "/outside/secret.txt";
|
||||
}
|
||||
return p;
|
||||
});
|
||||
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
|
||||
const p = typeof args[0] === "string" ? args[0] : "";
|
||||
if (p === candidate) {
|
||||
return makeSymlinkStat();
|
||||
}
|
||||
throw createEnoentError();
|
||||
});
|
||||
|
||||
const { respond, promise } = makeCall("agents.files.set", {
|
||||
agentId: "main",
|
||||
name: "AGENTS.md",
|
||||
content: "x",
|
||||
});
|
||||
await promise;
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }),
|
||||
);
|
||||
expect(mocks.fsOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows in-workspace symlink targets for get/set", async () => {
|
||||
const workspace = "/workspace/test-agent";
|
||||
const candidate = `${workspace}/AGENTS.md`;
|
||||
const target = `${workspace}/policies/AGENTS.md`;
|
||||
const targetStat = makeFileStat({ size: 7, mtimeMs: 1700, dev: 9, ino: 42 });
|
||||
|
||||
mocks.fsRealpath.mockImplementation(async (p: string) => {
|
||||
if (p === workspace) {
|
||||
return workspace;
|
||||
}
|
||||
if (p === candidate) {
|
||||
return target;
|
||||
}
|
||||
return p;
|
||||
});
|
||||
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
|
||||
const p = typeof args[0] === "string" ? args[0] : "";
|
||||
if (p === candidate) {
|
||||
return makeSymlinkStat({ dev: 9, ino: 41 });
|
||||
}
|
||||
if (p === target) {
|
||||
return targetStat;
|
||||
}
|
||||
throw createEnoentError();
|
||||
});
|
||||
mocks.fsStat.mockImplementation(async (...args: unknown[]) => {
|
||||
const p = typeof args[0] === "string" ? args[0] : "";
|
||||
if (p === target) {
|
||||
return targetStat;
|
||||
}
|
||||
throw createEnoentError();
|
||||
});
|
||||
mocks.fsOpen.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
stat: async () => targetStat,
|
||||
readFile: async () => Buffer.from("inside\n"),
|
||||
writeFile: async () => {},
|
||||
close: async () => {},
|
||||
}) as unknown,
|
||||
);
|
||||
|
||||
const getCall = makeCall("agents.files.get", { agentId: "main", name: "AGENTS.md" });
|
||||
await getCall.promise;
|
||||
expect(getCall.respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({
|
||||
file: expect.objectContaining({ missing: false, content: "inside\n" }),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
|
||||
const setCall = makeCall("agents.files.set", {
|
||||
agentId: "main",
|
||||
name: "AGENTS.md",
|
||||
content: "updated\n",
|
||||
});
|
||||
await setCall.promise;
|
||||
expect(setCall.respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
file: expect.objectContaining({ missing: false, content: "updated\n" }),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user