diff --git a/packages/cli/src/result-export.ts b/packages/cli/src/result-export.ts index 8e65a54da..00606a4a1 100644 --- a/packages/cli/src/result-export.ts +++ b/packages/cli/src/result-export.ts @@ -1,7 +1,7 @@ import { fileReferenceIdentity } from "@hypit/result/node"; import { randomUUID } from "node:crypto"; import { lstat, mkdir, rename, rm, writeFile } from "node:fs/promises"; -import { basename, dirname, join, relative, resolve, sep } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import type { BuildResultFileRef, @@ -35,7 +35,9 @@ async function pathExists(path: string): Promise { function containedPath(root: string, path: string): string { const target = resolve(root, path); const relation = relative(root, target); - assert(relation.length > 0 && relation !== ".." && !relation.startsWith(`..${sep}`), + // A cross-drive or UNC target makes relative() return an absolute path on Windows, + // which cannot start with ".." — reject it the same way as an escaping one. + assert(relation.length > 0 && relation !== ".." && !relation.startsWith(`..${sep}`) && !isAbsolute(relation), `Result file ${path} leaves export directory ${root}`); return target; } diff --git a/packages/protocol/src/build-id.ts b/packages/protocol/src/build-id.ts index c6405d8e0..01f9f9cd7 100644 --- a/packages/protocol/src/build-id.ts +++ b/packages/protocol/src/build-id.ts @@ -47,6 +47,6 @@ export function assertOrderedBuildId(value: string): void { export function assertBuildId(value: string): void { assert(value.length > 0 && value === value.trim(), "Build id must be a non-empty unpadded name"); assert(value !== "." && value !== "..", "Build id must not be a dot directory"); - assert(!value.includes("/") && !value.includes("\\") && !value.includes("\0"), + assert(!value.includes("/") && !value.includes("\\") && !value.includes("\0") && !value.includes(":"), "Build id must be one path-free name"); } diff --git a/packages/protocol/test/canonical.test.ts b/packages/protocol/test/canonical.test.ts index ca008ac3e..74a4ec8cd 100644 --- a/packages/protocol/test/canonical.test.ts +++ b/packages/protocol/test/canonical.test.ts @@ -35,7 +35,7 @@ test("Resource ids retain their narrow execution-reference syntax", () => { test("Build ids cannot address a parent or nested path", () => { assert.doesNotThrow(() => assertBuildId("build-19")); - for (const value of ["..", ".", "episode/19", "episode\\19", " padded "]) { + for (const value of ["..", ".", "episode/19", "episode\\19", " padded ", "c:", "d:build"]) { assert.throws(() => assertBuildId(value), /Build id/u); } }); diff --git a/packages/result/src/node/store.ts b/packages/result/src/node/store.ts index 89382a340..c815aa574 100644 --- a/packages/result/src/node/store.ts +++ b/packages/result/src/node/store.ts @@ -207,7 +207,9 @@ export function buildResultDirectory(root: string, build: string): string { const bucket = buildResultDateBucket(build)!; const target = resolve(directory, bucket, build); const relation = relative(directory, target); - assert(relation.length > 0 && relation !== ".." && !relation.startsWith(`..${sep}`), + // Cross-drive and UNC targets make relative() return an absolute path, which + // cannot start with ".." — reject it the same way as an escaping one. + assert(relation.length > 0 && relation !== ".." && !relation.startsWith(`..${sep}`) && !isAbsolute(relation), `Build ${build} leaves Build Result root ${directory}`); return target; } @@ -228,7 +230,7 @@ function containedResultPath(directory: string, path: string): string { const root = resolve(directory); const absolute = resolve(root, path); const relation = relative(root, absolute); - assert(relation !== ".." && !relation.startsWith(`..${sep}`), + assert(relation !== ".." && !relation.startsWith(`..${sep}`) && !isAbsolute(relation), `Build Result path ${path} leaves ${root}`); return absolute; } diff --git a/packages/runtime-local/src/config.ts b/packages/runtime-local/src/config.ts index bf2cf13b0..55a6fc861 100644 --- a/packages/runtime-local/src/config.ts +++ b/packages/runtime-local/src/config.ts @@ -1,7 +1,7 @@ import { fileExecutionLogs } from "./log.js"; import { constants } from "node:fs"; import { access, readFile, rm, stat } from "node:fs/promises"; -import { dirname, relative, resolve, sep } from "node:path"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; import { loadNodePackageSelection, @@ -883,7 +883,7 @@ function buildWorkPath(root: string, build: string): string { const work = resolve(root, "work"); const target = resolve(work, build); const relation = relative(work, target); - if (relation.length === 0 || relation === ".." || relation.startsWith(`..${sep}`)) { + if (relation.length === 0 || relation === ".." || relation.startsWith(`..${sep}`) || isAbsolute(relation)) { throw new Error(`Build ${build} leaves Runtime work root ${work}`); } return target;