fix(core): a project path typed in another letter case stays inside the project (#4514)

* fix(core): a project path typed in another letter case stays inside the project

* fix(core): project paths still resolve on volumes that refuse the native realpath
This commit is contained in:
Miguel Ángel
2026-09-26 03:00:47 +00:00
committed by GitHub
parent 23937497a9
commit 259f65c000
3 changed files with 63 additions and 5 deletions
+29
View File
@@ -0,0 +1,29 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
const realpathSync = Object.assign((path: string) => actual.realpathSync(path), {
native: () => {
throw Object.assign(new Error("EISDIR: illegal operation on a directory"), {
code: "EISDIR",
});
},
});
return { ...actual, realpathSync, default: { ...actual, realpathSync } };
});
const { isSafePath } = await import("./safePath.js");
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
it("still resolves paths on a volume whose native realpath refuses with EISDIR", () => {
const base = mkdtempSync(join(tmpdir(), "safepath-eisdir-"));
dirs.push(base);
expect(isSafePath(base, join(base, "file.txt"))).toBe(true);
expect(isSafePath(base, join(tmpdir(), "outside.txt"))).toBe(false);
});
+21 -2
View File
@@ -1,9 +1,20 @@
import { describe, it, expect, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { basename, dirname, join } from "node:path";
import { isSafePath, resolveWithinProject } from "./safePath.js";
const recased = (path: string) => join(dirname(path), basename(path).toUpperCase());
function caseInsensitive(): boolean {
const dir = mkdtempSync(join(tmpdir(), "safepath-probe-"));
try {
return existsSync(recased(dir));
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
describe("isSafePath", () => {
const tmpDirs: string[] = [];
@@ -101,6 +112,14 @@ describe("isSafePath", () => {
expect(isSafePath(baseLink, join(baseLink, "file.txt"))).toBe(true);
});
it.skipIf(!caseInsensitive())("admits an in-base path typed in another letter case", () => {
const base = tmpDir("safepath-case-");
writeFileSync(join(base, "file.txt"), "x");
expect(isSafePath(base, join(recased(base), "file.txt"))).toBe(true);
expect(isSafePath(recased(base), join(base, "new.txt"))).toBe(true);
expect(isSafePath(recased(base), join(tmpdir(), "outside.txt"))).toBe(false);
});
it("fails closed when the base directory does not exist", () => {
const base = join(tmpdir(), "safepath-does-not-exist-zzz", "nope");
expect(isSafePath(base, join(base, "file.txt"))).toBe(false);
+13 -3
View File
@@ -1,6 +1,16 @@
import { resolve, sep, join, dirname, basename } from "node:path";
import { lstatSync, realpathSync } from "node:fs";
function realpath(path: string): string {
try {
return realpathSync.native(path);
} catch (error) {
// Some Windows volumes (RAM disks) refuse the native call with EISDIR.
if ((error as NodeJS.ErrnoException).code === "EISDIR") return realpathSync(path);
throw error;
}
}
// realpath also fails for dangling/cyclic symlinks. Existing entries must not
// become missing segments: writes could follow them outside the project.
function isMissingPath(path: string): boolean {
@@ -25,7 +35,7 @@ function isMissingPath(path: string): boolean {
* symlink that lives *inside* `base` but points outside it (e.g.
* `base/link -> /etc`). A downstream `readFileSync`/`writeFileSync`/`statSync`
* then follows that link to a file outside `base`. To close this we canonicalize
* both sides with `realpathSync` before comparing.
* both sides with `realpathSync.native` (the on-disk letter case too) before comparing.
*
* The target may not exist yet (e.g. creating a new file), so we canonicalize the
* deepest *existing* ancestor and re-attach the trailing not-yet-existing
@@ -42,7 +52,7 @@ function isMissingPath(path: string): boolean {
export function isSafePath(base: string, resolved: string): boolean {
let baseReal: string;
try {
baseReal = realpathSync(resolve(base));
baseReal = realpath(resolve(base));
} catch {
// Base must exist and be resolvable; fail closed if not.
return false;
@@ -55,7 +65,7 @@ export function isSafePath(base: string, resolved: string): boolean {
for (;;) {
let ancestorReal: string;
try {
ancestorReal = realpathSync(probe);
ancestorReal = realpath(probe);
} catch {
if (!isMissingPath(probe)) return false;
const parent = dirname(probe);