mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 12:21:24 +00:00
fix: harden workspace boundary path resolution
This commit is contained in:
@@ -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 };
|
||||
|
||||
167
src/infra/boundary-path.test.ts
Normal file
167
src/infra/boundary-path.test.ts
Normal 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
511
src/infra/boundary-path.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user