mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 04:57:40 +00:00
refactor(security): unify path alias guard policies
This commit is contained in:
@@ -6,9 +6,9 @@ export async function assertNoHardlinkedFinalPath(params: {
|
||||
filePath: string;
|
||||
root: string;
|
||||
boundaryLabel: string;
|
||||
allowFinalHardlink?: boolean;
|
||||
allowFinalHardlinkForUnlink?: boolean;
|
||||
}): Promise<void> {
|
||||
if (params.allowFinalHardlink) {
|
||||
if (params.allowFinalHardlinkForUnlink) {
|
||||
return;
|
||||
}
|
||||
let stat: Awaited<ReturnType<typeof fs.stat>>;
|
||||
|
||||
89
src/infra/path-alias-guards.ts
Normal file
89
src/infra/path-alias-guards.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { assertNoHardlinkedFinalPath } from "./hardlink-guards.js";
|
||||
import { isNotFoundPathError, isPathInside } from "./path-guards.js";
|
||||
|
||||
export type PathAliasPolicy = {
|
||||
allowFinalSymlinkForUnlink?: boolean;
|
||||
allowFinalHardlinkForUnlink?: boolean;
|
||||
};
|
||||
|
||||
export const PATH_ALIAS_POLICIES = {
|
||||
strict: Object.freeze({
|
||||
allowFinalSymlinkForUnlink: false,
|
||||
allowFinalHardlinkForUnlink: false,
|
||||
}),
|
||||
unlinkTarget: Object.freeze({
|
||||
allowFinalSymlinkForUnlink: true,
|
||||
allowFinalHardlinkForUnlink: true,
|
||||
}),
|
||||
} as const;
|
||||
|
||||
export async function assertNoPathAliasEscape(params: {
|
||||
absolutePath: string;
|
||||
rootPath: string;
|
||||
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 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,
|
||||
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