mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 12:00:44 +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,5 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { sanitizeUntrustedFileName } from "openclaw/plugin-sdk/security-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
@@ -35,9 +36,7 @@ const AUDIO_MIME_MP3 = new Set(["audio/mpeg", "audio/mp3"]);
|
||||
const AUDIO_MIME_CAF = new Set(["audio/x-caf", "audio/caf"]);
|
||||
|
||||
function sanitizeFilename(input: string | undefined, fallback: string): string {
|
||||
const trimmed = input?.trim() ?? "";
|
||||
const base = trimmed ? path.basename(trimmed) : "";
|
||||
const name = base || fallback;
|
||||
const name = sanitizeUntrustedFileName(input ?? "", fallback);
|
||||
// Strip characters that could enable multipart header injection (CWE-93)
|
||||
return name.replace(/[\r\n"\\]/g, "_");
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
basenameFromMediaSource,
|
||||
safeFileURLToPath,
|
||||
readLocalFileFromRoots,
|
||||
} from "openclaw/plugin-sdk/file-access-runtime";
|
||||
import { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { lowercasePreservingWhitespace } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { sendBlueBubblesAttachment } from "./attachments.js";
|
||||
import { resolveBlueBubblesMessageId } from "./monitor-reply-cache.js";
|
||||
@@ -31,61 +26,6 @@ function assertMediaWithinLimit(sizeBytes: number, maxBytes?: number): void {
|
||||
throw new Error(`Media exceeds ${maxLabel}MB limit (got ${sizeLabel}MB)`);
|
||||
}
|
||||
|
||||
function resolveLocalMediaPath(source: string): string {
|
||||
if (!source.startsWith("file://")) {
|
||||
return source;
|
||||
}
|
||||
try {
|
||||
return safeFileURLToPath(source);
|
||||
} catch {
|
||||
throw new Error(`Invalid file:// URL: ${source}`);
|
||||
}
|
||||
}
|
||||
|
||||
function expandHomePath(input: string): string {
|
||||
if (input === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (input.startsWith("~/") || input.startsWith(`~${path.sep}`)) {
|
||||
return path.join(os.homedir(), input.slice(2));
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function resolveConfiguredPath(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Empty mediaLocalRoots entry is not allowed");
|
||||
}
|
||||
if (trimmed.startsWith("file://")) {
|
||||
try {
|
||||
return safeFileURLToPath(trimmed);
|
||||
} catch {
|
||||
throw new Error(`Invalid file:// URL in mediaLocalRoots: ${input}`);
|
||||
}
|
||||
}
|
||||
const resolved = expandHomePath(trimmed);
|
||||
if (!path.isAbsolute(resolved)) {
|
||||
throw new Error(`mediaLocalRoots entries must be absolute paths: ${input}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function isPathInsideRoot(candidate: string, root: string): boolean {
|
||||
const normalizedCandidate = path.normalize(candidate);
|
||||
const normalizedRoot = path.normalize(root);
|
||||
const rootWithSep = normalizedRoot.endsWith(path.sep)
|
||||
? normalizedRoot
|
||||
: normalizedRoot + path.sep;
|
||||
if (process.platform === "win32") {
|
||||
const candidateLower = lowercasePreservingWhitespace(normalizedCandidate);
|
||||
const rootLower = lowercasePreservingWhitespace(normalizedRoot);
|
||||
const rootWithSepLower = lowercasePreservingWhitespace(rootWithSep);
|
||||
return candidateLower === rootLower || candidateLower.startsWith(rootWithSepLower);
|
||||
}
|
||||
return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(rootWithSep);
|
||||
}
|
||||
|
||||
function resolveMediaLocalRoots(params: { cfg: OpenClawConfig; accountId?: string }): string[] {
|
||||
const account = resolveBlueBubblesAccount({
|
||||
cfg: params.cfg,
|
||||
@@ -111,60 +51,17 @@ async function assertLocalMediaPathAllowed(params: {
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedLocalPath = path.resolve(params.localPath);
|
||||
const supportsNoFollow = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
|
||||
const openFlags = fsConstants.O_RDONLY | (supportsNoFollow ? fsConstants.O_NOFOLLOW : 0);
|
||||
|
||||
for (const rootEntry of params.localRoots) {
|
||||
const resolvedRootInput = resolveConfiguredPath(rootEntry);
|
||||
const relativeToRoot = path.relative(resolvedRootInput, resolvedLocalPath);
|
||||
if (
|
||||
relativeToRoot.startsWith("..") ||
|
||||
path.isAbsolute(relativeToRoot) ||
|
||||
relativeToRoot === ""
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let rootReal: string;
|
||||
try {
|
||||
rootReal = await fs.realpath(resolvedRootInput);
|
||||
} catch {
|
||||
rootReal = path.resolve(resolvedRootInput);
|
||||
}
|
||||
const candidatePath = path.resolve(rootReal, relativeToRoot);
|
||||
|
||||
if (!isPathInsideRoot(candidatePath, rootReal)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let handle: Awaited<ReturnType<typeof fs.open>> | null = null;
|
||||
try {
|
||||
handle = await fs.open(candidatePath, openFlags);
|
||||
const realPath = await fs.realpath(candidatePath);
|
||||
if (!isPathInsideRoot(realPath, rootReal)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const realStat = await fs.stat(realPath);
|
||||
if (stat.ino !== realStat.ino || stat.dev !== realStat.dev) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = await handle.readFile();
|
||||
return { data, realPath, sizeBytes: stat.size };
|
||||
} catch {
|
||||
// Try next configured root.
|
||||
continue;
|
||||
} finally {
|
||||
if (handle) {
|
||||
await handle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
const localFile = await readLocalFileFromRoots({
|
||||
filePath: params.localPath,
|
||||
roots: params.localRoots,
|
||||
label: "mediaLocalRoots",
|
||||
});
|
||||
if (localFile) {
|
||||
return {
|
||||
data: localFile.buffer,
|
||||
realPath: localFile.realPath,
|
||||
sizeBytes: localFile.stat.size,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
@@ -244,9 +141,8 @@ export async function sendBlueBubblesMedia(params: {
|
||||
resolvedContentType = resolvedContentType ?? fetched.contentType ?? undefined;
|
||||
resolvedFilename = resolvedFilename ?? fetched.fileName;
|
||||
} else {
|
||||
const localPath = expandHomePath(resolveLocalMediaPath(source));
|
||||
const localFile = await assertLocalMediaPathAllowed({
|
||||
localPath,
|
||||
localPath: source,
|
||||
localRoots: mediaLocalRoots,
|
||||
accountId,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user