Skip to content

Commit c6748a8

Browse files
samzongsteipete
authored andcommitted
fix(memory): block extra path symlink traversal
## Considered and deferred - packages/memory-host-sdk/src/host/read-file.ts:77 [BOT-SCOPE]: Fully race-proof parent traversal would need a lower-level pinned/openat-style primitive; this diff fixes static symlink traversal and rejects symlink components before read.
1 parent d21c47d commit c6748a8

3 files changed

Lines changed: 127 additions & 2 deletions

File tree

packages/memory-host-sdk/src/host/fs-utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { configureFsSafePython } from "@openclaw/fs-safe/config";
22
export { root } from "@openclaw/fs-safe/root";
3-
export { isPathInside } from "@openclaw/fs-safe/path";
3+
export { isPathInside, isPathInsideWithRealpath } from "@openclaw/fs-safe/path";
44
export {
5+
assertNoSymlinkParents,
56
readRegularFile,
67
statRegularFile,
78
type RegularFileStatResult,
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { describe, expect, it } from "vitest";
5+
import { readMemoryFile } from "./read-file.js";
6+
7+
async function createDirectorySymlink(target: string, linkPath: string): Promise<boolean> {
8+
try {
9+
await fs.symlink(target, linkPath, "dir");
10+
return true;
11+
} catch (err) {
12+
const code = (err as NodeJS.ErrnoException).code;
13+
if (code === "EPERM" || code === "EACCES") {
14+
return false;
15+
}
16+
throw err;
17+
}
18+
}
19+
20+
describe("readMemoryFile", () => {
21+
it("returns empty text for missing files under extra path directories", async () => {
22+
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-read-file-"));
23+
try {
24+
const workspaceDir = path.join(tmpRoot, "workspace");
25+
const extraDir = path.join(tmpRoot, "extra");
26+
const missingPath = path.join(extraDir, "missing.md");
27+
await fs.mkdir(workspaceDir, { recursive: true });
28+
await fs.mkdir(extraDir, { recursive: true });
29+
30+
const result = await readMemoryFile({
31+
workspaceDir,
32+
extraPaths: [extraDir],
33+
relPath: missingPath,
34+
});
35+
36+
expect(result).toEqual({
37+
text: "",
38+
path: path.relative(workspaceDir, missingPath).replace(/\\/g, "/"),
39+
});
40+
} finally {
41+
await fs.rm(tmpRoot, { recursive: true, force: true });
42+
}
43+
});
44+
45+
it("rejects extra path reads through symlinked directory components", async () => {
46+
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-read-file-"));
47+
try {
48+
const workspaceDir = path.join(tmpRoot, "workspace");
49+
const extraDir = path.join(tmpRoot, "extra");
50+
const outsideDir = path.join(tmpRoot, "outside");
51+
await fs.mkdir(workspaceDir, { recursive: true });
52+
await fs.mkdir(extraDir, { recursive: true });
53+
await fs.mkdir(outsideDir, { recursive: true });
54+
await fs.writeFile(path.join(extraDir, "inside.md"), "inside", "utf-8");
55+
await fs.writeFile(path.join(outsideDir, "private.md"), "private", "utf-8");
56+
57+
const inside = await readMemoryFile({
58+
workspaceDir,
59+
extraPaths: [extraDir],
60+
relPath: path.join(extraDir, "inside.md"),
61+
});
62+
expect(inside.text).toBe("inside");
63+
64+
const insideLinkPath = path.join(extraDir, "inside-link");
65+
if (!(await createDirectorySymlink(extraDir, insideLinkPath))) {
66+
return;
67+
}
68+
await expect(
69+
readMemoryFile({
70+
workspaceDir,
71+
extraPaths: [extraDir],
72+
relPath: path.join(insideLinkPath, "inside.md"),
73+
}),
74+
).rejects.toThrow("path required");
75+
76+
const outsideLinkPath = path.join(extraDir, "link");
77+
if (!(await createDirectorySymlink(outsideDir, outsideLinkPath))) {
78+
return;
79+
}
80+
81+
await expect(
82+
readMemoryFile({
83+
workspaceDir,
84+
extraPaths: [extraDir],
85+
relPath: path.join(outsideLinkPath, "private.md"),
86+
}),
87+
).rejects.toThrow("path required");
88+
await expect(
89+
readMemoryFile({
90+
workspaceDir,
91+
extraPaths: [extraDir],
92+
relPath: path.join(outsideLinkPath, "missing.md"),
93+
}),
94+
).rejects.toThrow("path required");
95+
} finally {
96+
await fs.rm(tmpRoot, { recursive: true, force: true });
97+
}
98+
});
99+
});

packages/memory-host-sdk/src/host/read-file.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import {
77
type OpenClawConfig,
88
} from "./config-utils.js";
99
import {
10+
assertNoSymlinkParents,
1011
isFileMissingError,
1112
isPathInside,
13+
isPathInsideWithRealpath,
1214
readRegularFile,
1315
root,
1416
statRegularFile,
@@ -20,6 +22,29 @@ import {
2022
type MemoryReadResult,
2123
} from "./read-file-shared.js";
2224

25+
async function isAllowedAdditionalDirectoryPath(
26+
additionalPath: string,
27+
absPath: string,
28+
): Promise<boolean> {
29+
if (!isPathInside(additionalPath, absPath)) {
30+
return false;
31+
}
32+
try {
33+
await assertNoSymlinkParents({ rootDir: additionalPath, targetPath: absPath });
34+
} catch {
35+
return false;
36+
}
37+
if (!isPathInsideWithRealpath(additionalPath, absPath)) {
38+
try {
39+
await fs.lstat(absPath);
40+
} catch (err) {
41+
return isFileMissingError(err);
42+
}
43+
return false;
44+
}
45+
return true;
46+
}
47+
2348
export async function readMemoryFile(params: {
2449
workspaceDir: string;
2550
extraPaths?: string[];
@@ -49,7 +74,7 @@ export async function readMemoryFile(params: {
4974
continue;
5075
}
5176
if (stat.isDirectory()) {
52-
if (isPathInside(additionalPath, absPath)) {
77+
if (await isAllowedAdditionalDirectoryPath(additionalPath, absPath)) {
5378
const candidateStat = await fs.lstat(absPath).catch(() => null);
5479
if (candidateStat?.isSymbolicLink()) {
5580
continue;

0 commit comments

Comments
 (0)