fix(infra): avoid EISDIR leak to messaging when Read targets directory (Closes #31186)

This commit is contained in:
倪汉杰0668001185
2026-03-02 10:55:36 +08:00
committed by Peter Steinberger
parent 8a4d8c889c
commit 6398a0ba8f
2 changed files with 40 additions and 1 deletions

View File

@@ -73,6 +73,22 @@ describe("fs-safe", () => {
).rejects.toMatchObject({ code: "outside-workspace" });
});
it("rejects directory path within root without leaking EISDIR (issue #31186)", async () => {
const root = await tempDirs.make("openclaw-fs-safe-root-");
await fs.mkdir(path.join(root, "memory"), { recursive: true });
await expect(
openFileWithinRoot({ rootDir: root, relativePath: "memory" }),
).rejects.toMatchObject({ code: expect.stringMatching(/invalid-path|not-file/) });
const err = await openFileWithinRoot({
rootDir: root,
relativePath: "memory",
}).catch((e: unknown) => e);
expect(err).toBeInstanceOf(SafeOpenError);
expect((err as SafeOpenError).message).not.toMatch(/EISDIR/i);
});
it("reads a file within root", async () => {
const root = await tempDirs.make("openclaw-fs-safe-root-");
await fs.writeFile(path.join(root, "inside.txt"), "inside");

View File

@@ -7,7 +7,12 @@ import path from "node:path";
import { sameFileIdentity } from "./file-identity.js";
import { expandHomePrefix } from "./home-dir.js";
import { assertNoPathAliasEscape } from "./path-alias-guards.js";
import { isNotFoundPathError, isPathInside, isSymlinkOpenError } from "./path-guards.js";
import {
hasNodeErrorCode,
isNotFoundPathError,
isPathInside,
isSymlinkOpenError,
} from "./path-guards.js";
export type SafeOpenErrorCode =
| "invalid-path"
@@ -68,6 +73,20 @@ async function openVerifiedLocalFile(
rejectHardlinks?: boolean;
},
): Promise<SafeOpenResult> {
// Reject directories before opening so we never surface EISDIR to callers (e.g. tool
// results that get sent to messaging channels). See openclaw/openclaw#31186.
try {
const preStat = await fs.lstat(filePath);
if (preStat.isDirectory()) {
throw new SafeOpenError("not-file", "not a file");
}
} catch (err) {
if (err instanceof SafeOpenError) {
throw err;
}
// ENOENT and other lstat errors: fall through and let fs.open handle.
}
let handle: FileHandle;
try {
handle = await fs.open(filePath, OPEN_READ_FLAGS);
@@ -78,6 +97,10 @@ async function openVerifiedLocalFile(
if (isSymlinkOpenError(err)) {
throw new SafeOpenError("symlink", "symlink open blocked", { cause: err });
}
// Defensive: if open still throws EISDIR (e.g. race), sanitize so it never leaks.
if (hasNodeErrorCode(err, "EISDIR")) {
throw new SafeOpenError("not-file", "not a file");
}
throw err;
}