mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:50:43 +00:00
[codex] Extract filesystem safety primitives (#77918)
* refactor: extract filesystem safety primitives * refactor: use fs-safe for file access helpers * refactor: reuse fs-safe for media reads * refactor: use fs-safe for image reads * refactor: reuse fs-safe in qqbot media opener * refactor: reuse fs-safe for local media checks * refactor: consume cleaner fs-safe api * refactor: align fs-safe json option names * fix: preserve fs-safe migration contracts * refactor: use fs-safe primitive subpaths * refactor: use grouped fs-safe subpaths * refactor: align fs-safe api usage * refactor: adapt private state store api * chore: refresh proof gate * refactor: follow fs-safe json api split * refactor: follow reduced fs-safe surface * build: default fs-safe python helper off * fix: preserve fs-safe plugin sdk aliases * refactor: consolidate fs-safe usage * refactor: unify fs-safe store usage * refactor: trim fs-safe temp workspace usage * refactor: hide low-level fs-safe primitives * build: use published fs-safe package * fix: preserve outbound recovery durability after rebase * chore: refresh pr checks
This commit is contained in:
committed by
GitHub
parent
61481eb34f
commit
538605ff44
@@ -1,51 +1,13 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { writeFileFromPathWithinRoot } from "../infra/fs-safe.js";
|
||||
import { sanitizeUntrustedFileName } from "./safe-filename.js";
|
||||
|
||||
function buildSiblingTempPath(targetPath: string): string {
|
||||
const id = crypto.randomUUID();
|
||||
const safeTail = sanitizeUntrustedFileName(path.basename(targetPath), "output.bin");
|
||||
return path.join(path.dirname(targetPath), `.openclaw-output-${id}-${safeTail}.part`);
|
||||
}
|
||||
import { writeViaSiblingTempPath as writeViaSiblingTempPathBase } from "../sdk-security-runtime.js";
|
||||
|
||||
export async function writeViaSiblingTempPath(params: {
|
||||
rootDir: string;
|
||||
targetPath: string;
|
||||
writeTemp: (tempPath: string) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const rootDir = await fs
|
||||
.realpath(path.resolve(params.rootDir))
|
||||
.catch(() => path.resolve(params.rootDir));
|
||||
const requestedTargetPath = path.resolve(params.targetPath);
|
||||
const targetPath = await fs
|
||||
.realpath(path.dirname(requestedTargetPath))
|
||||
.then((realDir) => path.join(realDir, path.basename(requestedTargetPath)))
|
||||
.catch(() => requestedTargetPath);
|
||||
const relativeTargetPath = path.relative(rootDir, targetPath);
|
||||
if (
|
||||
!relativeTargetPath ||
|
||||
relativeTargetPath === ".." ||
|
||||
relativeTargetPath.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relativeTargetPath)
|
||||
) {
|
||||
throw new Error("Target path is outside the allowed root");
|
||||
}
|
||||
const tempPath = buildSiblingTempPath(targetPath);
|
||||
let renameSucceeded = false;
|
||||
try {
|
||||
await params.writeTemp(tempPath);
|
||||
await writeFileFromPathWithinRoot({
|
||||
rootDir,
|
||||
relativePath: relativeTargetPath,
|
||||
sourcePath: tempPath,
|
||||
mkdir: false,
|
||||
});
|
||||
renameSucceeded = true;
|
||||
} finally {
|
||||
if (!renameSucceeded) {
|
||||
await fs.rm(tempPath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
await writeViaSiblingTempPathBase({
|
||||
...params,
|
||||
fallbackFileName: "output.bin",
|
||||
tempPrefix: ".openclaw-output-",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { SafeOpenError, openFileWithinRoot } from "../infra/fs-safe.js";
|
||||
import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
export {
|
||||
resolveExistingPathsWithinRoot,
|
||||
pathScope,
|
||||
resolvePathsWithinRoot,
|
||||
resolvePathWithinRoot,
|
||||
resolveStrictExistingPathsWithinRoot,
|
||||
resolveWritablePathWithinRoot,
|
||||
} from "../sdk-security-runtime.js";
|
||||
|
||||
const DEFAULT_FALLBACK_BROWSER_TMP_DIR = "/tmp/openclaw";
|
||||
|
||||
@@ -28,241 +33,3 @@ const DEFAULT_BROWSER_TMP_DIR = canUseNodeFs()
|
||||
export const DEFAULT_TRACE_DIR = DEFAULT_BROWSER_TMP_DIR;
|
||||
export const DEFAULT_DOWNLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, "downloads");
|
||||
export const DEFAULT_UPLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, "uploads");
|
||||
|
||||
type InvalidPathResult = { ok: false; error: string };
|
||||
type ResolvePathsWithinRootParams = {
|
||||
rootDir: string;
|
||||
requestedPaths: string[];
|
||||
scopeLabel: string;
|
||||
};
|
||||
type ResolvePathsWithinRootResult = { ok: true; paths: string[] } | InvalidPathResult;
|
||||
|
||||
function invalidPath(scopeLabel: string): InvalidPathResult {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Invalid path: must stay within ${scopeLabel}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveRealPathIfExists(targetPath: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await fs.realpath(targetPath);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveTrustedRootRealPath(rootDir: string): Promise<string | undefined> {
|
||||
try {
|
||||
const rootLstat = await fs.lstat(rootDir);
|
||||
if (!rootLstat.isDirectory() || rootLstat.isSymbolicLink()) {
|
||||
return undefined;
|
||||
}
|
||||
return await fs.realpath(rootDir);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function validateCanonicalPathWithinRoot(params: {
|
||||
rootRealPath: string;
|
||||
candidatePath: string;
|
||||
expect: "directory" | "file";
|
||||
}): Promise<"ok" | "not-found" | "invalid"> {
|
||||
try {
|
||||
const candidateLstat = await fs.lstat(params.candidatePath);
|
||||
if (candidateLstat.isSymbolicLink()) {
|
||||
return "invalid";
|
||||
}
|
||||
if (params.expect === "directory" && !candidateLstat.isDirectory()) {
|
||||
return "invalid";
|
||||
}
|
||||
if (params.expect === "file" && !candidateLstat.isFile()) {
|
||||
return "invalid";
|
||||
}
|
||||
if (params.expect === "file" && candidateLstat.nlink > 1) {
|
||||
return "invalid";
|
||||
}
|
||||
const candidateRealPath = await fs.realpath(params.candidatePath);
|
||||
return isPathInside(params.rootRealPath, candidateRealPath) ? "ok" : "invalid";
|
||||
} catch (err) {
|
||||
return isNotFoundPathError(err) ? "not-found" : "invalid";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePathWithinRoot(params: {
|
||||
rootDir: string;
|
||||
requestedPath: string;
|
||||
scopeLabel: string;
|
||||
defaultFileName?: string;
|
||||
}): { ok: true; path: string } | { ok: false; error: string } {
|
||||
const root = path.resolve(params.rootDir);
|
||||
const raw = params.requestedPath.trim();
|
||||
if (!raw) {
|
||||
if (!params.defaultFileName) {
|
||||
return { ok: false, error: "path is required" };
|
||||
}
|
||||
return { ok: true, path: path.join(root, params.defaultFileName) };
|
||||
}
|
||||
const resolved = path.resolve(root, raw);
|
||||
const rel = path.relative(root, resolved);
|
||||
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
return { ok: false, error: `Invalid path: must stay within ${params.scopeLabel}` };
|
||||
}
|
||||
return { ok: true, path: resolved };
|
||||
}
|
||||
|
||||
export async function resolveWritablePathWithinRoot(params: {
|
||||
rootDir: string;
|
||||
requestedPath: string;
|
||||
scopeLabel: string;
|
||||
defaultFileName?: string;
|
||||
}): Promise<{ ok: true; path: string } | { ok: false; error: string }> {
|
||||
const lexical = resolvePathWithinRoot(params);
|
||||
if (!lexical.ok) {
|
||||
return lexical;
|
||||
}
|
||||
|
||||
const rootDir = path.resolve(params.rootDir);
|
||||
const rootRealPath = await resolveTrustedRootRealPath(rootDir);
|
||||
if (!rootRealPath) {
|
||||
return invalidPath(params.scopeLabel);
|
||||
}
|
||||
|
||||
const requestedPath = lexical.path;
|
||||
const parentDir = path.dirname(requestedPath);
|
||||
const parentStatus = await validateCanonicalPathWithinRoot({
|
||||
rootRealPath,
|
||||
candidatePath: parentDir,
|
||||
expect: "directory",
|
||||
});
|
||||
if (parentStatus !== "ok") {
|
||||
return invalidPath(params.scopeLabel);
|
||||
}
|
||||
|
||||
const targetStatus = await validateCanonicalPathWithinRoot({
|
||||
rootRealPath,
|
||||
candidatePath: requestedPath,
|
||||
expect: "file",
|
||||
});
|
||||
if (targetStatus === "invalid") {
|
||||
return invalidPath(params.scopeLabel);
|
||||
}
|
||||
|
||||
return lexical;
|
||||
}
|
||||
|
||||
export function resolvePathsWithinRoot(
|
||||
params: ResolvePathsWithinRootParams,
|
||||
): ResolvePathsWithinRootResult {
|
||||
const resolvedPaths: string[] = [];
|
||||
for (const raw of params.requestedPaths) {
|
||||
const pathResult = resolvePathWithinRoot({
|
||||
rootDir: params.rootDir,
|
||||
requestedPath: raw,
|
||||
scopeLabel: params.scopeLabel,
|
||||
});
|
||||
if (!pathResult.ok) {
|
||||
return { ok: false, error: pathResult.error };
|
||||
}
|
||||
resolvedPaths.push(pathResult.path);
|
||||
}
|
||||
return { ok: true, paths: resolvedPaths };
|
||||
}
|
||||
|
||||
export async function resolveExistingPathsWithinRoot(
|
||||
params: ResolvePathsWithinRootParams,
|
||||
): Promise<ResolvePathsWithinRootResult> {
|
||||
return await resolveCheckedPathsWithinRoot(params, true);
|
||||
}
|
||||
|
||||
export async function resolveStrictExistingPathsWithinRoot(
|
||||
params: ResolvePathsWithinRootParams,
|
||||
): Promise<ResolvePathsWithinRootResult> {
|
||||
return await resolveCheckedPathsWithinRoot(params, false);
|
||||
}
|
||||
|
||||
async function resolveCheckedPathsWithinRoot(
|
||||
params: ResolvePathsWithinRootParams,
|
||||
allowMissingFallback: boolean,
|
||||
): Promise<ResolvePathsWithinRootResult> {
|
||||
const rootDir = path.resolve(params.rootDir);
|
||||
// Keep historical behavior for missing roots and rely on openFileWithinRoot for final checks.
|
||||
const rootRealPath = await resolveRealPathIfExists(rootDir);
|
||||
|
||||
const isInRoot = (relativePath: string) =>
|
||||
Boolean(relativePath) && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
|
||||
|
||||
const resolveExistingRelativePath = async (
|
||||
requestedPath: string,
|
||||
): Promise<
|
||||
{ ok: true; relativePath: string; fallbackPath: string } | { ok: false; error: string }
|
||||
> => {
|
||||
const raw = requestedPath.trim();
|
||||
const lexicalPathResult = resolvePathWithinRoot({
|
||||
rootDir,
|
||||
requestedPath,
|
||||
scopeLabel: params.scopeLabel,
|
||||
});
|
||||
if (lexicalPathResult.ok) {
|
||||
return {
|
||||
ok: true,
|
||||
relativePath: path.relative(rootDir, lexicalPathResult.path),
|
||||
fallbackPath: lexicalPathResult.path,
|
||||
};
|
||||
}
|
||||
if (!rootRealPath || !raw || !path.isAbsolute(raw)) {
|
||||
return lexicalPathResult;
|
||||
}
|
||||
try {
|
||||
const resolvedExistingPath = await fs.realpath(raw);
|
||||
const relativePath = path.relative(rootRealPath, resolvedExistingPath);
|
||||
if (!isInRoot(relativePath)) {
|
||||
return lexicalPathResult;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
relativePath,
|
||||
fallbackPath: resolvedExistingPath,
|
||||
};
|
||||
} catch {
|
||||
return lexicalPathResult;
|
||||
}
|
||||
};
|
||||
|
||||
const resolvedPaths: string[] = [];
|
||||
for (const raw of params.requestedPaths) {
|
||||
const pathResult = await resolveExistingRelativePath(raw);
|
||||
if (!pathResult.ok) {
|
||||
return { ok: false, error: pathResult.error };
|
||||
}
|
||||
|
||||
let opened: Awaited<ReturnType<typeof openFileWithinRoot>> | undefined;
|
||||
try {
|
||||
opened = await openFileWithinRoot({
|
||||
rootDir,
|
||||
relativePath: pathResult.relativePath,
|
||||
});
|
||||
resolvedPaths.push(opened.realPath);
|
||||
} catch (err) {
|
||||
if (allowMissingFallback && err instanceof SafeOpenError && err.code === "not-found") {
|
||||
// Preserve historical behavior for paths that do not exist yet.
|
||||
resolvedPaths.push(pathResult.fallbackPath);
|
||||
continue;
|
||||
}
|
||||
if (err instanceof SafeOpenError && err.code === "outside-workspace") {
|
||||
return {
|
||||
ok: false,
|
||||
error: `File is outside ${params.scopeLabel}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: `Invalid path: must stay within ${params.scopeLabel} and be a regular non-symlink file`,
|
||||
};
|
||||
} finally {
|
||||
await opened?.handle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
return { ok: true, paths: resolvedPaths };
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
withRouteTabContext,
|
||||
} from "./agent.shared.js";
|
||||
import { EXISTING_SESSION_LIMITS } from "./existing-session-limits.js";
|
||||
import { DEFAULT_UPLOAD_DIR, resolveExistingPathsWithinRoot } from "./path-output.js";
|
||||
import { DEFAULT_UPLOAD_DIR, pathScope } from "./path-output.js";
|
||||
import type { BrowserRouteRegistrar } from "./types.js";
|
||||
import {
|
||||
asyncBrowserRoute,
|
||||
@@ -43,11 +43,9 @@ export function registerBrowserAgentActHookRoutes(
|
||||
ctx,
|
||||
targetId,
|
||||
run: async ({ profileCtx, cdpUrl, tab }) => {
|
||||
const uploadPathsResult = await resolveExistingPathsWithinRoot({
|
||||
rootDir: DEFAULT_UPLOAD_DIR,
|
||||
requestedPaths: paths,
|
||||
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
|
||||
});
|
||||
const uploadPathsResult = await pathScope(DEFAULT_UPLOAD_DIR, {
|
||||
label: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
|
||||
}).existing(paths);
|
||||
if (!uploadPathsResult.ok) {
|
||||
res.status(400).json({ error: uploadPathsResult.error });
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { resolveWritablePathWithinRoot } from "./path-output.js";
|
||||
import { pathScope } from "./path-output.js";
|
||||
import type { BrowserResponse } from "./types.js";
|
||||
|
||||
export async function ensureOutputRootDir(rootDir: string): Promise<void> {
|
||||
@@ -17,12 +17,10 @@ export async function resolveWritableOutputPathOrRespond(params: {
|
||||
if (params.ensureRootDir) {
|
||||
await ensureOutputRootDir(params.rootDir);
|
||||
}
|
||||
const pathResult = await resolveWritablePathWithinRoot({
|
||||
rootDir: params.rootDir,
|
||||
requestedPath: params.requestedPath,
|
||||
scopeLabel: params.scopeLabel,
|
||||
defaultFileName: params.defaultFileName,
|
||||
});
|
||||
const pathResult = await pathScope(params.rootDir, { label: params.scopeLabel }).writable(
|
||||
params.requestedPath,
|
||||
{ defaultName: params.defaultFileName },
|
||||
);
|
||||
if (!pathResult.ok) {
|
||||
params.res.status(400).json({ error: pathResult.error });
|
||||
return null;
|
||||
|
||||
@@ -2,6 +2,7 @@ export {
|
||||
DEFAULT_DOWNLOAD_DIR,
|
||||
DEFAULT_TRACE_DIR,
|
||||
DEFAULT_UPLOAD_DIR,
|
||||
pathScope,
|
||||
resolveExistingPathsWithinRoot,
|
||||
resolveWritablePathWithinRoot,
|
||||
} from "../paths.js";
|
||||
|
||||
@@ -1,27 +1 @@
|
||||
import path from "node:path";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
export function sanitizeUntrustedFileName(fileName: string, fallbackName: string): string {
|
||||
const trimmed = normalizeOptionalString(fileName) ?? "";
|
||||
if (!trimmed) {
|
||||
return fallbackName;
|
||||
}
|
||||
let base = path.posix.basename(trimmed);
|
||||
base = path.win32.basename(base);
|
||||
let cleaned = "";
|
||||
for (let i = 0; i < base.length; i++) {
|
||||
const code = base.charCodeAt(i);
|
||||
if (code < 0x20 || code === 0x7f) {
|
||||
continue;
|
||||
}
|
||||
cleaned += base[i];
|
||||
}
|
||||
base = cleaned.trim();
|
||||
if (!base || base === "." || base === "..") {
|
||||
return fallbackName;
|
||||
}
|
||||
if (base.length > 200) {
|
||||
base = base.slice(0, 200);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
export { sanitizeUntrustedFileName } from "../sdk-security-runtime.js";
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
export {
|
||||
SafeOpenError,
|
||||
openFileWithinRoot,
|
||||
writeFileFromPathWithinRoot,
|
||||
} from "../sdk-security-runtime.js";
|
||||
export { root, FsSafeError } from "../sdk-security-runtime.js";
|
||||
|
||||
@@ -9,13 +9,20 @@ export {
|
||||
isPrivateNetworkAllowedByPolicy,
|
||||
matchesHostnameAllowlist,
|
||||
normalizeHostname,
|
||||
openFileWithinRoot,
|
||||
pathScope,
|
||||
redactSensitiveText,
|
||||
resolveExistingPathsWithinRoot,
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
resolvePathsWithinRoot,
|
||||
resolvePathWithinRoot,
|
||||
root,
|
||||
safeEqualSecret,
|
||||
SafeOpenError,
|
||||
sanitizeUntrustedFileName,
|
||||
resolveStrictExistingPathsWithinRoot,
|
||||
resolveWritablePathWithinRoot,
|
||||
FsSafeError,
|
||||
SsrFBlockedError,
|
||||
writeViaSiblingTempPath,
|
||||
wrapExternalContent,
|
||||
writeFileFromPathWithinRoot,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
export type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/security-runtime";
|
||||
|
||||
Reference in New Issue
Block a user