fix: harden workspace boundary path resolution

This commit is contained in:
Peter Steinberger
2026-02-26 13:19:55 +01:00
parent ecb2053fdd
commit 46eba86b45
8 changed files with 767 additions and 177 deletions

View File

@@ -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;

View File

@@ -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,
});

View File

@@ -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));
}

View File

@@ -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<ReturnType<typeof fs.lstat>>;
@@ -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<ReturnType<typeof fs.stat>>;
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<FileMeta | null> {

View File

@@ -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<typeof fs, "realpathSync">, 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<BoundaryFileOpenResult> {
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 };

View File

@@ -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<T>(prefix: string, run: (root: string) => Promise<T>): Promise<T> {
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);
}
});
});
});

511
src/infra/boundary-path.ts Normal file
View File

@@ -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<ResolvedBoundaryPath> {
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<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: Awaited<ReturnType<typeof fsp.lstat>>;
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<string> {
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<boolean> {
try {
await fsp.lstat(targetPath);
return true;
} catch (error) {
if (isNotFoundPathError(error)) {
return false;
}
throw error;
}
}
async function resolveSymlinkHopPath(symlinkPath: string): Promise<string> {
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);
}
}

View File

@@ -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<void> {
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<string> {
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;
}