mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 15:30:39 +00:00
fix: harden workspace boundary path resolution
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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