mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user