diff --git a/packages/parsers/src/assetPaths.test.ts b/packages/parsers/src/assetPaths.test.ts new file mode 100644 index 0000000000..9e2a530e73 --- /dev/null +++ b/packages/parsers/src/assetPaths.test.ts @@ -0,0 +1,23 @@ +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { isPathInside } from "./assetPaths.js"; + +describe("isPathInside", () => { + it("accepts the parent itself and nested children", () => { + expect(isPathInside(resolve("/project"), resolve("/project"))).toBe(true); + expect(isPathInside(resolve("/project/assets/logo.png"), resolve("/project"))).toBe(true); + }); + + it("rejects paths above the parent and siblings sharing its prefix", () => { + expect(isPathInside(resolve("/project/../secret.txt"), resolve("/project"))).toBe(false); + expect(isPathInside(resolve("/"), resolve("/project"))).toBe(false); + expect(isPathInside(resolve("/project-other/logo.png"), resolve("/project"))).toBe(false); + }); + + it("accepts a child whose name starts with two dots", () => { + // `..assets` is an ordinary directory name, not a parent-dir segment. + expect(isPathInside(resolve("/project/..assets/logo.png"), resolve("/project"))).toBe(true); + expect(isPathInside(resolve("/project/..intro.mp4"), resolve("/project"))).toBe(true); + }); +}); diff --git a/packages/parsers/src/assetPaths.ts b/packages/parsers/src/assetPaths.ts index 390bc66492..a4821134a2 100644 --- a/packages/parsers/src/assetPaths.ts +++ b/packages/parsers/src/assetPaths.ts @@ -36,12 +36,14 @@ export function isNonRelativeUrl(val: string): boolean { * Cross-platform containment check: is `childPath` inside `parentPath`? * Equality counts as "inside". */ +const LEADING_PARENT_SEGMENT_RE = /^\.\.(?:[\\/]|$)/; + export function isPathInside(childPath: string, parentPath: string): boolean { const absChild = resolve(childPath); const absParent = resolve(parentPath); if (absChild === absParent) return true; const rel = relative(absParent, absChild); - return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); + return rel !== "" && !LEADING_PARENT_SEGMENT_RE.test(rel) && !isAbsolute(rel); } export { diff --git a/packages/producer/src/utils/paths.test.ts b/packages/producer/src/utils/paths.test.ts index abbffb0001..fee8a379f4 100644 --- a/packages/producer/src/utils/paths.test.ts +++ b/packages/producer/src/utils/paths.test.ts @@ -49,6 +49,29 @@ describe("isPathInside", () => { expect(isPathInside(resolve("/foo/bar/../../etc/passwd"), resolve("/foo/bar"))).toBe(false); }); + it("returns true for a child whose name starts with two dots", () => { + // `..assets` is an ordinary directory name, not a parent-dir segment. + expect(isPathInside(resolve("/foo/bar/..assets/logo.png"), resolve("/foo/bar"))).toBe(true); + expect(isPathInside(resolve("/foo/bar/..intro.mp4"), resolve("/foo/bar"))).toBe(true); + expect( + isPathInside( + win32.resolve("C:\\project\\..fonts\\Inter.woff2"), + win32.resolve("C:\\project"), + { + pathModule: win32, + }, + ), + ).toBe(true); + }); + + it("still rejects a Windows child above the parent", () => { + expect( + isPathInside(win32.resolve("C:\\project\\..\\secret.txt"), win32.resolve("C:\\project"), { + pathModule: win32, + }), + ).toBe(false); + }); + it("normalises trailing slashes on parent", () => { expect(isPathInside(resolve("/foo/bar/baz"), resolve("/foo/bar/"))).toBe(true); }); diff --git a/packages/producer/src/utils/paths.ts b/packages/producer/src/utils/paths.ts index 7ff314517f..14327a081a 100644 --- a/packages/producer/src/utils/paths.ts +++ b/packages/producer/src/utils/paths.ts @@ -28,6 +28,8 @@ type PathModuleLike = { isAbsolute: (path: string) => boolean; }; +const LEADING_PARENT_SEGMENT_RE = /^\.\.(?:[\\/]|$)/; + type IsPathInsideOptions = { pathModule?: PathModuleLike; }; @@ -59,7 +61,7 @@ export function isPathInside( // `relative()` returns "" when paths are equal, ".." or "..\\foo" when child // is above the parent, and an absolute path when they live on different // drives/volumes (Windows) — none of which count as "inside". - return rel !== "" && !rel.startsWith("..") && !isPathAbsolute(rel); + return rel !== "" && !LEADING_PARENT_SEGMENT_RE.test(rel) && !isPathAbsolute(rel); } /**