diff --git a/src/agents/sandbox-paths.test.ts b/src/agents/sandbox-paths.test.ts index 305da9eb40a..3deb30a0179 100644 --- a/src/agents/sandbox-paths.test.ts +++ b/src/agents/sandbox-paths.test.ts @@ -195,6 +195,26 @@ describe("resolveSandboxedMediaSource", () => { }); }); + it("rejects sandbox symlink escapes when the outside leaf does not exist yet", async () => { + if (process.platform === "win32") { + return; + } + await withSandboxRoot(async (sandboxDir) => { + const outsideDir = await fs.mkdtemp( + path.join(process.cwd(), "sandbox-media-outside-missing-"), + ); + const linkDir = path.join(sandboxDir, "escape-link"); + await fs.symlink(outsideDir, linkDir); + try { + const missingOutsidePath = path.join(linkDir, "new-file.txt"); + await expectSandboxRejection(missingOutsidePath, sandboxDir, /symlink|sandbox/i); + } finally { + await fs.rm(linkDir, { force: true }); + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + }); + it("rejects hardlinked OpenClaw tmp paths to outside files", async () => { if (process.platform === "win32") { return; diff --git a/src/agents/sandbox-paths.ts b/src/agents/sandbox-paths.ts index 7cb026c28a4..1d46d02db63 100644 --- a/src/agents/sandbox-paths.ts +++ b/src/agents/sandbox-paths.ts @@ -71,7 +71,7 @@ export async function assertSandboxPath(params: { }; await assertNoPathAliasEscape({ absolutePath: resolved.resolved, - rootPath: path.resolve(params.root), + rootPath: params.root, boundaryLabel: "sandbox root", policy, }); diff --git a/src/agents/sandbox/host-paths.ts b/src/agents/sandbox/host-paths.ts index 7b99ed0a53c..f80ba2c8ee5 100644 --- a/src/agents/sandbox/host-paths.ts +++ b/src/agents/sandbox/host-paths.ts @@ -1,5 +1,5 @@ -import { existsSync, realpathSync } from "node:fs"; import { posix } from "node:path"; +import { resolvePathViaExistingAncestorSync } from "../../infra/boundary-path.js"; /** * Normalize a POSIX host path: resolve `.`, `..`, collapse `//`, strip trailing `/`. @@ -17,31 +17,5 @@ export function resolveSandboxHostPathViaExistingAncestor(sourcePath: string): s if (!sourcePath.startsWith("/")) { return sourcePath; } - - const normalized = normalizeSandboxHostPath(sourcePath); - let current = normalized; - const missingSegments: string[] = []; - - while (current !== "/" && !existsSync(current)) { - missingSegments.unshift(posix.basename(current)); - const parent = posix.dirname(current); - if (parent === current) { - break; - } - current = parent; - } - - if (!existsSync(current)) { - return normalized; - } - - try { - const resolvedAncestor = normalizeSandboxHostPath(realpathSync.native(current)); - if (missingSegments.length === 0) { - return resolvedAncestor; - } - return normalizeSandboxHostPath(posix.join(resolvedAncestor, ...missingSegments)); - } catch { - return normalized; - } + return normalizeSandboxHostPath(resolvePathViaExistingAncestorSync(sourcePath)); } diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index 0e132e5b82a..6098dd7be25 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -30,7 +30,8 @@ import { loadConfig, writeConfigFile } from "../../config/config.js"; import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js"; import { sameFileIdentity } from "../../infra/file-identity.js"; import { SafeOpenError, readLocalFileSafely } from "../../infra/fs-safe.js"; -import { isNotFoundPathError, isPathInside } from "../../infra/path-guards.js"; +import { assertNoPathAliasEscape } from "../../infra/path-alias-guards.js"; +import { isNotFoundPathError } from "../../infra/path-guards.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js"; import { resolveUserPath } from "../../utils.js"; import { @@ -143,8 +144,19 @@ async function resolveAgentWorkspaceFilePath(params: { const requestPath = path.join(params.workspaceDir, params.name); const workspaceReal = await resolveWorkspaceRealPath(params.workspaceDir); const candidatePath = path.resolve(workspaceReal, params.name); - if (!isPathInside(workspaceReal, candidatePath)) { - return { kind: "invalid", requestPath, reason: "path escapes workspace root" }; + + try { + await assertNoPathAliasEscape({ + absolutePath: candidatePath, + rootPath: workspaceReal, + boundaryLabel: "workspace root", + }); + } catch (error) { + return { + kind: "invalid", + requestPath, + reason: error instanceof Error ? error.message : "path escapes workspace root", + }; } let candidateLstat: Awaited>; @@ -169,27 +181,28 @@ async function resolveAgentWorkspaceFilePath(params: { if (params.allowMissing) { return { kind: "missing", requestPath, ioPath: candidatePath, workspaceReal }; } - return { kind: "invalid", requestPath, reason: "symlink target not found" }; + return { kind: "invalid", requestPath, reason: "file not found" }; } throw err; } - if (!isPathInside(workspaceReal, targetReal)) { - return { kind: "invalid", requestPath, reason: "symlink target escapes workspace root" }; - } + let targetStat: Awaited>; try { - const targetStat = await fs.stat(targetReal); - if (!targetStat.isFile()) { - return { kind: "invalid", requestPath, reason: "symlink target is not a file" }; - } - if (targetStat.nlink > 1) { - return { kind: "invalid", requestPath, reason: "hardlinked file target not allowed" }; - } + targetStat = await fs.stat(targetReal); } catch (err) { - if (isNotFoundPathError(err) && params.allowMissing) { - return { kind: "missing", requestPath, ioPath: targetReal, workspaceReal }; + if (isNotFoundPathError(err)) { + if (params.allowMissing) { + return { kind: "missing", requestPath, ioPath: targetReal, workspaceReal }; + } + return { kind: "invalid", requestPath, reason: "file not found" }; } throw err; } + if (!targetStat.isFile()) { + return { kind: "invalid", requestPath, reason: "path is not a regular file" }; + } + if (targetStat.nlink > 1) { + return { kind: "invalid", requestPath, reason: "hardlinked file path not allowed" }; + } return { kind: "ready", requestPath, ioPath: targetReal, workspaceReal }; } @@ -200,11 +213,8 @@ async function resolveAgentWorkspaceFilePath(params: { return { kind: "invalid", requestPath, reason: "hardlinked file path not allowed" }; } - const candidateReal = await fs.realpath(candidatePath).catch(() => candidatePath); - if (!isPathInside(workspaceReal, candidateReal)) { - return { kind: "invalid", requestPath, reason: "resolved file escapes workspace root" }; - } - return { kind: "ready", requestPath, ioPath: candidateReal, workspaceReal }; + const targetReal = await fs.realpath(candidatePath).catch(() => candidatePath); + return { kind: "ready", requestPath, ioPath: targetReal, workspaceReal }; } async function statFileSafely(filePath: string): Promise { diff --git a/src/infra/boundary-file-read.ts b/src/infra/boundary-file-read.ts index fd3e0e64917..9b0c5e9a510 100644 --- a/src/infra/boundary-file-read.ts +++ b/src/infra/boundary-file-read.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -import { assertNoPathAliasEscape, type PathAliasPolicy } from "./path-alias-guards.js"; -import { isNotFoundPathError, isPathInside } from "./path-guards.js"; +import { resolveBoundaryPath, resolveBoundaryPathSync } from "./boundary-path.js"; +import type { PathAliasPolicy } from "./path-alias-guards.js"; import { openVerifiedFileSync, type SafeOpenSyncFailureReason } from "./safe-open-sync.js"; type BoundaryReadFs = Pick< @@ -36,14 +36,6 @@ export type OpenBoundaryFileParams = OpenBoundaryFileSyncParams & { aliasPolicy?: PathAliasPolicy; }; -function safeRealpathSync(ioFs: Pick, value: string): string { - try { - return path.resolve(ioFs.realpathSync(value)); - } catch { - return path.resolve(value); - } -} - export function canUseBoundaryFileOpen(ioFs: typeof fs): boolean { return ( typeof ioFs.openSync === "function" && @@ -60,52 +52,21 @@ export function canUseBoundaryFileOpen(ioFs: typeof fs): boolean { export function openBoundaryFileSync(params: OpenBoundaryFileSyncParams): BoundaryFileOpenResult { const ioFs = params.ioFs ?? fs; const absolutePath = path.resolve(params.absolutePath); - const rootPath = path.resolve(params.rootPath); - const rootRealPath = params.rootRealPath - ? path.resolve(params.rootRealPath) - : safeRealpathSync(ioFs, rootPath); - let resolvedPath = absolutePath; - const lexicalInsideRoot = isPathInside(rootPath, absolutePath); + let resolvedPath: string; + let rootRealPath: string; try { - const candidateRealPath = path.resolve(ioFs.realpathSync(absolutePath)); - if ( - !params.skipLexicalRootCheck && - !lexicalInsideRoot && - !isPathInside(rootRealPath, candidateRealPath) - ) { - return { - ok: false, - reason: "validation", - error: new Error( - `Path escapes ${params.boundaryLabel}: ${absolutePath} (root: ${rootPath})`, - ), - }; - } - if (!isPathInside(rootRealPath, candidateRealPath)) { - return { - ok: false, - reason: "validation", - error: new Error( - `Path resolves outside ${params.boundaryLabel}: ${absolutePath} (root: ${rootRealPath})`, - ), - }; - } - resolvedPath = candidateRealPath; + const resolved = resolveBoundaryPathSync({ + absolutePath, + rootPath: params.rootPath, + rootCanonicalPath: params.rootRealPath, + boundaryLabel: params.boundaryLabel, + skipLexicalRootCheck: params.skipLexicalRootCheck, + }); + resolvedPath = resolved.canonicalPath; + rootRealPath = resolved.rootCanonicalPath; } catch (error) { - if (!params.skipLexicalRootCheck && !lexicalInsideRoot) { - return { - ok: false, - reason: "validation", - error: new Error( - `Path escapes ${params.boundaryLabel}: ${absolutePath} (root: ${rootPath})`, - ), - }; - } - if (!isNotFoundPathError(error)) { - // Keep resolvedPath as lexical path; openVerifiedFileSync below will produce - // a canonical error classification for missing/unreadable targets. - } + return { ok: false, reason: "validation", error }; } const opened = openVerifiedFileSync({ @@ -131,11 +92,13 @@ export async function openBoundaryFile( params: OpenBoundaryFileParams, ): Promise { try { - await assertNoPathAliasEscape({ + await resolveBoundaryPath({ absolutePath: params.absolutePath, rootPath: params.rootPath, + rootCanonicalPath: params.rootRealPath, boundaryLabel: params.boundaryLabel, policy: params.aliasPolicy, + skipLexicalRootCheck: params.skipLexicalRootCheck, }); } catch (error) { return { ok: false, reason: "validation", error }; diff --git a/src/infra/boundary-path.test.ts b/src/infra/boundary-path.test.ts new file mode 100644 index 00000000000..caafdc070a6 --- /dev/null +++ b/src/infra/boundary-path.test.ts @@ -0,0 +1,167 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveBoundaryPath, resolveBoundaryPathSync } from "./boundary-path.js"; +import { isPathInside } from "./path-guards.js"; + +async function withTempRoot(prefix: string, run: (root: string) => Promise): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + try { + return await run(root); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +function createSeededRandom(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0x100000000; + }; +} + +describe("resolveBoundaryPath", () => { + it("resolves symlink parents with non-existent leafs inside root", async () => { + if (process.platform === "win32") { + return; + } + + await withTempRoot("openclaw-boundary-path-", async (base) => { + const root = path.join(base, "workspace"); + const targetDir = path.join(root, "target-dir"); + const linkPath = path.join(root, "alias"); + await fs.mkdir(targetDir, { recursive: true }); + await fs.symlink(targetDir, linkPath); + + const unresolved = path.join(linkPath, "missing.txt"); + const result = await resolveBoundaryPath({ + absolutePath: unresolved, + rootPath: root, + boundaryLabel: "sandbox root", + }); + + const targetReal = await fs.realpath(targetDir); + expect(result.exists).toBe(false); + expect(result.kind).toBe("missing"); + expect(result.canonicalPath).toBe(path.join(targetReal, "missing.txt")); + expect(isPathInside(result.rootCanonicalPath, result.canonicalPath)).toBe(true); + }); + }); + + it("blocks dangling symlink leaf escapes outside root", async () => { + if (process.platform === "win32") { + return; + } + + await withTempRoot("openclaw-boundary-path-", async (base) => { + const root = path.join(base, "workspace"); + const outside = path.join(base, "outside"); + const linkPath = path.join(root, "alias-out"); + await fs.mkdir(root, { recursive: true }); + await fs.mkdir(outside, { recursive: true }); + await fs.symlink(outside, linkPath); + const dangling = path.join(linkPath, "missing.txt"); + + await expect( + resolveBoundaryPath({ + absolutePath: dangling, + rootPath: root, + boundaryLabel: "sandbox root", + }), + ).rejects.toThrow(/Symlink escapes sandbox root/i); + expect(() => + resolveBoundaryPathSync({ + absolutePath: dangling, + rootPath: root, + boundaryLabel: "sandbox root", + }), + ).toThrow(/Symlink escapes sandbox root/i); + }); + }); + + it("allows final symlink only when unlink policy opts in", async () => { + if (process.platform === "win32") { + return; + } + + await withTempRoot("openclaw-boundary-path-", async (base) => { + const root = path.join(base, "workspace"); + const outside = path.join(base, "outside"); + const outsideFile = path.join(outside, "target.txt"); + const linkPath = path.join(root, "link.txt"); + await fs.mkdir(root, { recursive: true }); + await fs.mkdir(outside, { recursive: true }); + await fs.writeFile(outsideFile, "x", "utf8"); + await fs.symlink(outsideFile, linkPath); + + await expect( + resolveBoundaryPath({ + absolutePath: linkPath, + rootPath: root, + boundaryLabel: "sandbox root", + }), + ).rejects.toThrow(/Symlink escapes sandbox root/i); + + const allowed = await resolveBoundaryPath({ + absolutePath: linkPath, + rootPath: root, + boundaryLabel: "sandbox root", + policy: { allowFinalSymlinkForUnlink: true }, + }); + const rootReal = await fs.realpath(root); + expect(allowed.exists).toBe(true); + expect(allowed.kind).toBe("symlink"); + expect(allowed.canonicalPath).toBe(path.join(rootReal, "link.txt")); + }); + }); + + it("maintains containment invariant across randomized alias cases", async () => { + if (process.platform === "win32") { + return; + } + + await withTempRoot("openclaw-boundary-path-fuzz-", async (base) => { + const root = path.join(base, "workspace"); + const outside = path.join(base, "outside"); + const safeTarget = path.join(root, "safe-target"); + await fs.mkdir(root, { recursive: true }); + await fs.mkdir(outside, { recursive: true }); + await fs.mkdir(safeTarget, { recursive: true }); + + const rand = createSeededRandom(0x5eed1234); + for (let idx = 0; idx < 64; idx += 1) { + const token = Math.floor(rand() * 1_000_000) + .toString(16) + .padStart(5, "0"); + const safeName = `safe-${idx}-${token}`; + const useLink = rand() > 0.5; + const safeBase = useLink ? path.join(root, `safe-link-${idx}`) : path.join(root, safeName); + if (useLink) { + await fs.symlink(safeTarget, safeBase); + } else { + await fs.mkdir(safeBase, { recursive: true }); + } + const safeCandidate = path.join(safeBase, `new-${token}.txt`); + const safeResolved = await resolveBoundaryPath({ + absolutePath: safeCandidate, + rootPath: root, + boundaryLabel: "sandbox root", + }); + expect(isPathInside(safeResolved.rootCanonicalPath, safeResolved.canonicalPath)).toBe(true); + + const escapeLink = path.join(root, `escape-${idx}`); + await fs.symlink(outside, escapeLink); + const unsafeCandidate = path.join(escapeLink, `new-${token}.txt`); + await expect( + resolveBoundaryPath({ + absolutePath: unsafeCandidate, + rootPath: root, + boundaryLabel: "sandbox root", + }), + ).rejects.toThrow(/Symlink escapes sandbox root/i); + } + }); + }); +}); diff --git a/src/infra/boundary-path.ts b/src/infra/boundary-path.ts new file mode 100644 index 00000000000..eb5715e8d91 --- /dev/null +++ b/src/infra/boundary-path.ts @@ -0,0 +1,511 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { isNotFoundPathError, isPathInside } from "./path-guards.js"; + +export type BoundaryPathIntent = "read" | "write" | "create" | "delete" | "stat"; + +export type BoundaryPathAliasPolicy = { + allowFinalSymlinkForUnlink?: boolean; + allowFinalHardlinkForUnlink?: boolean; +}; + +export const BOUNDARY_PATH_ALIAS_POLICIES = { + strict: Object.freeze({ + allowFinalSymlinkForUnlink: false, + allowFinalHardlinkForUnlink: false, + }), + unlinkTarget: Object.freeze({ + allowFinalSymlinkForUnlink: true, + allowFinalHardlinkForUnlink: true, + }), +} as const; + +export type ResolveBoundaryPathParams = { + absolutePath: string; + rootPath: string; + boundaryLabel: string; + intent?: BoundaryPathIntent; + policy?: BoundaryPathAliasPolicy; + skipLexicalRootCheck?: boolean; + rootCanonicalPath?: string; +}; + +export type ResolvedBoundaryPathKind = "missing" | "file" | "directory" | "symlink" | "other"; + +export type ResolvedBoundaryPath = { + absolutePath: string; + canonicalPath: string; + rootPath: string; + rootCanonicalPath: string; + relativePath: string; + exists: boolean; + kind: ResolvedBoundaryPathKind; +}; + +export async function resolveBoundaryPath( + params: ResolveBoundaryPathParams, +): Promise { + const rootPath = path.resolve(params.rootPath); + const absolutePath = path.resolve(params.absolutePath); + const rootCanonicalPath = params.rootCanonicalPath + ? path.resolve(params.rootCanonicalPath) + : await resolvePathViaExistingAncestor(rootPath); + const lexicalInside = isPathInside(rootPath, absolutePath); + + if (!params.skipLexicalRootCheck && !lexicalInside) { + throw pathEscapeError({ + boundaryLabel: params.boundaryLabel, + rootPath, + absolutePath, + }); + } + + if (!lexicalInside) { + const canonicalPath = await resolvePathViaExistingAncestor(absolutePath); + assertInsideBoundary({ + boundaryLabel: params.boundaryLabel, + rootCanonicalPath, + candidatePath: canonicalPath, + absolutePath, + }); + const kind = await getPathKind(absolutePath, false); + return { + absolutePath, + canonicalPath, + rootPath, + rootCanonicalPath, + relativePath: relativeInsideRoot(rootCanonicalPath, canonicalPath), + exists: kind.exists, + kind: kind.kind, + }; + } + + return resolveBoundaryPathLexicalAsync({ + params, + absolutePath, + rootPath, + rootCanonicalPath, + }); +} + +export function resolveBoundaryPathSync(params: ResolveBoundaryPathParams): ResolvedBoundaryPath { + const rootPath = path.resolve(params.rootPath); + const absolutePath = path.resolve(params.absolutePath); + const rootCanonicalPath = params.rootCanonicalPath + ? path.resolve(params.rootCanonicalPath) + : resolvePathViaExistingAncestorSync(rootPath); + const lexicalInside = isPathInside(rootPath, absolutePath); + + if (!params.skipLexicalRootCheck && !lexicalInside) { + throw pathEscapeError({ + boundaryLabel: params.boundaryLabel, + rootPath, + absolutePath, + }); + } + + if (!lexicalInside) { + const canonicalPath = resolvePathViaExistingAncestorSync(absolutePath); + assertInsideBoundary({ + boundaryLabel: params.boundaryLabel, + rootCanonicalPath, + candidatePath: canonicalPath, + absolutePath, + }); + const kind = getPathKindSync(absolutePath, false); + return { + absolutePath, + canonicalPath, + rootPath, + rootCanonicalPath, + relativePath: relativeInsideRoot(rootCanonicalPath, canonicalPath), + exists: kind.exists, + kind: kind.kind, + }; + } + + return resolveBoundaryPathLexicalSync({ + params, + absolutePath, + rootPath, + rootCanonicalPath, + }); +} + +async function resolveBoundaryPathLexicalAsync(params: { + params: ResolveBoundaryPathParams; + absolutePath: string; + rootPath: string; + rootCanonicalPath: string; +}): Promise { + const relative = path.relative(params.rootPath, params.absolutePath); + const segments = relative.split(path.sep).filter(Boolean); + const allowFinalSymlink = params.params.policy?.allowFinalSymlinkForUnlink === true; + let canonicalCursor = params.rootCanonicalPath; + let lexicalCursor = params.rootPath; + let preserveFinalSymlink = false; + + for (let idx = 0; idx < segments.length; idx += 1) { + const segment = segments[idx] ?? ""; + const isLast = idx === segments.length - 1; + lexicalCursor = path.join(lexicalCursor, segment); + + let stat: Awaited>; + try { + stat = await fsp.lstat(lexicalCursor); + } catch (error) { + if (isNotFoundPathError(error)) { + const missingSuffix = segments.slice(idx); + canonicalCursor = path.resolve(canonicalCursor, ...missingSuffix); + assertInsideBoundary({ + boundaryLabel: params.params.boundaryLabel, + rootCanonicalPath: params.rootCanonicalPath, + candidatePath: canonicalCursor, + absolutePath: params.absolutePath, + }); + break; + } + throw error; + } + + if (!stat.isSymbolicLink()) { + canonicalCursor = path.resolve(canonicalCursor, segment); + assertInsideBoundary({ + boundaryLabel: params.params.boundaryLabel, + rootCanonicalPath: params.rootCanonicalPath, + candidatePath: canonicalCursor, + absolutePath: params.absolutePath, + }); + continue; + } + + if (allowFinalSymlink && isLast) { + preserveFinalSymlink = true; + canonicalCursor = path.resolve(canonicalCursor, segment); + assertInsideBoundary({ + boundaryLabel: params.params.boundaryLabel, + rootCanonicalPath: params.rootCanonicalPath, + candidatePath: canonicalCursor, + absolutePath: params.absolutePath, + }); + break; + } + + const linkCanonical = await resolveSymlinkHopPath(lexicalCursor); + if (!isPathInside(params.rootCanonicalPath, linkCanonical)) { + throw symlinkEscapeError({ + boundaryLabel: params.params.boundaryLabel, + rootCanonicalPath: params.rootCanonicalPath, + symlinkPath: lexicalCursor, + }); + } + canonicalCursor = linkCanonical; + lexicalCursor = linkCanonical; + } + + assertInsideBoundary({ + boundaryLabel: params.params.boundaryLabel, + rootCanonicalPath: params.rootCanonicalPath, + candidatePath: canonicalCursor, + absolutePath: params.absolutePath, + }); + const kind = await getPathKind(params.absolutePath, preserveFinalSymlink); + return { + absolutePath: params.absolutePath, + canonicalPath: canonicalCursor, + rootPath: params.rootPath, + rootCanonicalPath: params.rootCanonicalPath, + relativePath: relativeInsideRoot(params.rootCanonicalPath, canonicalCursor), + exists: kind.exists, + kind: kind.kind, + }; +} + +function resolveBoundaryPathLexicalSync(params: { + params: ResolveBoundaryPathParams; + absolutePath: string; + rootPath: string; + rootCanonicalPath: string; +}): ResolvedBoundaryPath { + const relative = path.relative(params.rootPath, params.absolutePath); + const segments = relative.split(path.sep).filter(Boolean); + const allowFinalSymlink = params.params.policy?.allowFinalSymlinkForUnlink === true; + let canonicalCursor = params.rootCanonicalPath; + let lexicalCursor = params.rootPath; + let preserveFinalSymlink = false; + + for (let idx = 0; idx < segments.length; idx += 1) { + const segment = segments[idx] ?? ""; + const isLast = idx === segments.length - 1; + lexicalCursor = path.join(lexicalCursor, segment); + + let stat: fs.Stats; + try { + stat = fs.lstatSync(lexicalCursor); + } catch (error) { + if (isNotFoundPathError(error)) { + const missingSuffix = segments.slice(idx); + canonicalCursor = path.resolve(canonicalCursor, ...missingSuffix); + assertInsideBoundary({ + boundaryLabel: params.params.boundaryLabel, + rootCanonicalPath: params.rootCanonicalPath, + candidatePath: canonicalCursor, + absolutePath: params.absolutePath, + }); + break; + } + throw error; + } + + if (!stat.isSymbolicLink()) { + canonicalCursor = path.resolve(canonicalCursor, segment); + assertInsideBoundary({ + boundaryLabel: params.params.boundaryLabel, + rootCanonicalPath: params.rootCanonicalPath, + candidatePath: canonicalCursor, + absolutePath: params.absolutePath, + }); + continue; + } + + if (allowFinalSymlink && isLast) { + preserveFinalSymlink = true; + canonicalCursor = path.resolve(canonicalCursor, segment); + assertInsideBoundary({ + boundaryLabel: params.params.boundaryLabel, + rootCanonicalPath: params.rootCanonicalPath, + candidatePath: canonicalCursor, + absolutePath: params.absolutePath, + }); + break; + } + + const linkCanonical = resolveSymlinkHopPathSync(lexicalCursor); + if (!isPathInside(params.rootCanonicalPath, linkCanonical)) { + throw symlinkEscapeError({ + boundaryLabel: params.params.boundaryLabel, + rootCanonicalPath: params.rootCanonicalPath, + symlinkPath: lexicalCursor, + }); + } + canonicalCursor = linkCanonical; + lexicalCursor = linkCanonical; + } + + assertInsideBoundary({ + boundaryLabel: params.params.boundaryLabel, + rootCanonicalPath: params.rootCanonicalPath, + candidatePath: canonicalCursor, + absolutePath: params.absolutePath, + }); + const kind = getPathKindSync(params.absolutePath, preserveFinalSymlink); + return { + absolutePath: params.absolutePath, + canonicalPath: canonicalCursor, + rootPath: params.rootPath, + rootCanonicalPath: params.rootCanonicalPath, + relativePath: relativeInsideRoot(params.rootCanonicalPath, canonicalCursor), + exists: kind.exists, + kind: kind.kind, + }; +} + +export async function resolvePathViaExistingAncestor(targetPath: string): Promise { + const normalized = path.resolve(targetPath); + let cursor = normalized; + const missingSuffix: string[] = []; + + while (!isFilesystemRoot(cursor) && !(await pathExists(cursor))) { + missingSuffix.unshift(path.basename(cursor)); + const parent = path.dirname(cursor); + if (parent === cursor) { + break; + } + cursor = parent; + } + + if (!(await pathExists(cursor))) { + return normalized; + } + + try { + const resolvedAncestor = path.resolve(await fsp.realpath(cursor)); + if (missingSuffix.length === 0) { + return resolvedAncestor; + } + return path.resolve(resolvedAncestor, ...missingSuffix); + } catch { + return normalized; + } +} + +export function resolvePathViaExistingAncestorSync(targetPath: string): string { + const normalized = path.resolve(targetPath); + let cursor = normalized; + const missingSuffix: string[] = []; + + while (!isFilesystemRoot(cursor) && !fs.existsSync(cursor)) { + missingSuffix.unshift(path.basename(cursor)); + const parent = path.dirname(cursor); + if (parent === cursor) { + break; + } + cursor = parent; + } + + if (!fs.existsSync(cursor)) { + return normalized; + } + + try { + const resolvedAncestor = path.resolve(fs.realpathSync.native(cursor)); + if (missingSuffix.length === 0) { + return resolvedAncestor; + } + return path.resolve(resolvedAncestor, ...missingSuffix); + } catch { + return normalized; + } +} + +async function getPathKind( + absolutePath: string, + preserveFinalSymlink: boolean, +): Promise<{ exists: boolean; kind: ResolvedBoundaryPathKind }> { + try { + const stat = preserveFinalSymlink + ? await fsp.lstat(absolutePath) + : await fsp.stat(absolutePath); + return { exists: true, kind: toResolvedKind(stat) }; + } catch (error) { + if (isNotFoundPathError(error)) { + return { exists: false, kind: "missing" }; + } + throw error; + } +} + +function getPathKindSync( + absolutePath: string, + preserveFinalSymlink: boolean, +): { exists: boolean; kind: ResolvedBoundaryPathKind } { + try { + const stat = preserveFinalSymlink ? fs.lstatSync(absolutePath) : fs.statSync(absolutePath); + return { exists: true, kind: toResolvedKind(stat) }; + } catch (error) { + if (isNotFoundPathError(error)) { + return { exists: false, kind: "missing" }; + } + throw error; + } +} + +function toResolvedKind(stat: fs.Stats): ResolvedBoundaryPathKind { + if (stat.isFile()) { + return "file"; + } + if (stat.isDirectory()) { + return "directory"; + } + if (stat.isSymbolicLink()) { + return "symlink"; + } + return "other"; +} + +function relativeInsideRoot(rootPath: string, targetPath: string): string { + const relative = path.relative(path.resolve(rootPath), path.resolve(targetPath)); + if (!relative || relative === ".") { + return ""; + } + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return ""; + } + return relative; +} + +function assertInsideBoundary(params: { + boundaryLabel: string; + rootCanonicalPath: string; + candidatePath: string; + absolutePath: string; +}): void { + if (isPathInside(params.rootCanonicalPath, params.candidatePath)) { + return; + } + throw new Error( + `Path resolves outside ${params.boundaryLabel} (${shortPath(params.rootCanonicalPath)}): ${shortPath(params.absolutePath)}`, + ); +} + +function pathEscapeError(params: { + boundaryLabel: string; + rootPath: string; + absolutePath: string; +}): Error { + return new Error( + `Path escapes ${params.boundaryLabel} (${shortPath(params.rootPath)}): ${shortPath(params.absolutePath)}`, + ); +} + +function symlinkEscapeError(params: { + boundaryLabel: string; + rootCanonicalPath: string; + symlinkPath: string; +}): Error { + return new Error( + `Symlink escapes ${params.boundaryLabel} (${shortPath(params.rootCanonicalPath)}): ${shortPath(params.symlinkPath)}`, + ); +} + +function shortPath(value: string): string { + const home = os.homedir(); + if (value.startsWith(home)) { + return `~${value.slice(home.length)}`; + } + return value; +} + +function isFilesystemRoot(candidate: string): boolean { + return path.parse(candidate).root === candidate; +} + +async function pathExists(targetPath: string): Promise { + try { + await fsp.lstat(targetPath); + return true; + } catch (error) { + if (isNotFoundPathError(error)) { + return false; + } + throw error; + } +} + +async function resolveSymlinkHopPath(symlinkPath: string): Promise { + try { + return path.resolve(await fsp.realpath(symlinkPath)); + } catch (error) { + if (!isNotFoundPathError(error)) { + throw error; + } + const linkTarget = await fsp.readlink(symlinkPath); + const linkAbsolute = path.resolve(path.dirname(symlinkPath), linkTarget); + return resolvePathViaExistingAncestor(linkAbsolute); + } +} + +function resolveSymlinkHopPathSync(symlinkPath: string): string { + try { + return path.resolve(fs.realpathSync.native(symlinkPath)); + } catch (error) { + if (!isNotFoundPathError(error)) { + throw error; + } + const linkTarget = fs.readlinkSync(symlinkPath); + const linkAbsolute = path.resolve(path.dirname(symlinkPath), linkTarget); + return resolvePathViaExistingAncestorSync(linkAbsolute); + } +} diff --git a/src/infra/path-alias-guards.ts b/src/infra/path-alias-guards.ts index 86d08a3e44a..e7b0aa42a0e 100644 --- a/src/infra/path-alias-guards.ts +++ b/src/infra/path-alias-guards.ts @@ -1,24 +1,13 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; +import { + BOUNDARY_PATH_ALIAS_POLICIES, + resolveBoundaryPath, + type BoundaryPathAliasPolicy, +} from "./boundary-path.js"; import { assertNoHardlinkedFinalPath } from "./hardlink-guards.js"; -import { isNotFoundPathError, isPathInside } from "./path-guards.js"; -export type PathAliasPolicy = { - allowFinalSymlinkForUnlink?: boolean; - allowFinalHardlinkForUnlink?: boolean; -}; +export type PathAliasPolicy = BoundaryPathAliasPolicy; -export const PATH_ALIAS_POLICIES = { - strict: Object.freeze({ - allowFinalSymlinkForUnlink: false, - allowFinalHardlinkForUnlink: false, - }), - unlinkTarget: Object.freeze({ - allowFinalSymlinkForUnlink: true, - allowFinalHardlinkForUnlink: true, - }), -} as const; +export const PATH_ALIAS_POLICIES = BOUNDARY_PATH_ALIAS_POLICIES; export async function assertNoPathAliasEscape(params: { absolutePath: string; @@ -26,64 +15,20 @@ export async function assertNoPathAliasEscape(params: { boundaryLabel: string; policy?: PathAliasPolicy; }): Promise { - const root = path.resolve(params.rootPath); - const target = path.resolve(params.absolutePath); - if (!isPathInside(root, target)) { - throw new Error( - `Path escapes ${params.boundaryLabel} (${shortPath(root)}): ${shortPath(params.absolutePath)}`, - ); + const resolved = await resolveBoundaryPath({ + absolutePath: params.absolutePath, + rootPath: params.rootPath, + boundaryLabel: params.boundaryLabel, + policy: params.policy, + }); + const allowFinalSymlink = params.policy?.allowFinalSymlinkForUnlink === true; + if (allowFinalSymlink && resolved.kind === "symlink") { + return; } - const relative = path.relative(root, target); - if (relative) { - const rootReal = await tryRealpath(root); - const parts = relative.split(path.sep).filter(Boolean); - let current = root; - for (let idx = 0; idx < parts.length; idx += 1) { - current = path.join(current, parts[idx] ?? ""); - const isLast = idx === parts.length - 1; - try { - const stat = await fs.lstat(current); - if (!stat.isSymbolicLink()) { - continue; - } - if (params.policy?.allowFinalSymlinkForUnlink && isLast) { - return; - } - const symlinkTarget = await tryRealpath(current); - if (!isPathInside(rootReal, symlinkTarget)) { - throw new Error( - `Symlink escapes ${params.boundaryLabel} (${shortPath(rootReal)}): ${shortPath(current)}`, - ); - } - current = symlinkTarget; - } catch (error) { - if (isNotFoundPathError(error)) { - break; - } - throw error; - } - } - } - await assertNoHardlinkedFinalPath({ - filePath: target, - root, + filePath: resolved.absolutePath, + root: resolved.rootPath, boundaryLabel: params.boundaryLabel, allowFinalHardlinkForUnlink: params.policy?.allowFinalHardlinkForUnlink, }); } - -async function tryRealpath(value: string): Promise { - try { - return await fs.realpath(value); - } catch { - return path.resolve(value); - } -} - -function shortPath(value: string) { - if (value.startsWith(os.homedir())) { - return `~${value.slice(os.homedir().length)}`; - } - return value; -}