mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 21:01:43 +03:00
refactor(media): harden localRoots bypass (#16739)
Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 89dce69f5094bef7247b2510d27165e504cb820c Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Reviewed-by: @steipete
This commit is contained in:
committed by
GitHub
parent
b607c41a52
commit
683aa09b55
@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Media: accept `MEDIA:`-prefixed paths (lenient whitespace) when loading outbound media to prevent `ENOENT` for tool-returned local media paths. (#13107) Thanks @mcaxtr.
|
- Media: accept `MEDIA:`-prefixed paths (lenient whitespace) when loading outbound media to prevent `ENOENT` for tool-returned local media paths. (#13107) Thanks @mcaxtr.
|
||||||
- Agents/Image tool: allow workspace-local image paths by including the active workspace directory in local media allowlists, and trust sandbox-validated paths in image loaders to prevent false "not under an allowed directory" rejections. (#15541)
|
- Agents/Image tool: allow workspace-local image paths by including the active workspace directory in local media allowlists, and trust sandbox-validated paths in image loaders to prevent false "not under an allowed directory" rejections. (#15541)
|
||||||
- Agents/Image tool: propagate the effective workspace root into tool wiring so workspace-local image paths are accepted by default when running without an explicit `workspaceDir`. (#16722)
|
- Agents/Image tool: propagate the effective workspace root into tool wiring so workspace-local image paths are accepted by default when running without an explicit `workspaceDir`. (#16722)
|
||||||
|
- Media/Security: harden local media allowlist bypasses by requiring an explicit `readFile` override when callers mark paths as validated, and reject filesystem-root `localRoots` entries. (#16739)
|
||||||
- Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla.
|
- Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla.
|
||||||
- Cron: prevent `cron list`/`cron status` from silently skipping past-due recurring jobs by using maintenance recompute semantics. (#16156) Thanks @zerone0x.
|
- Cron: prevent `cron list`/`cron status` from silently skipping past-due recurring jobs by using maintenance recompute semantics. (#16156) Thanks @zerone0x.
|
||||||
- Cron: repair missing/corrupt `nextRunAtMs` for the updated job without globally recomputing unrelated due jobs during `cron update`. (#15750)
|
- Cron: repair missing/corrupt `nextRunAtMs` for the updated job without globally recomputing unrelated due jobs during `cron update`. (#15750)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
|
|||||||
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
|
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
|
||||||
import { createTtsTool } from "./tools/tts-tool.js";
|
import { createTtsTool } from "./tools/tts-tool.js";
|
||||||
import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
|
import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
|
||||||
|
import { resolveWorkspaceRoot } from "./workspace-dir.js";
|
||||||
|
|
||||||
export function createOpenClawTools(options?: {
|
export function createOpenClawTools(options?: {
|
||||||
sandboxBrowserBridgeUrl?: string;
|
sandboxBrowserBridgeUrl?: string;
|
||||||
@@ -60,7 +61,7 @@ export function createOpenClawTools(options?: {
|
|||||||
/** If true, omit the message tool from the tool list. */
|
/** If true, omit the message tool from the tool list. */
|
||||||
disableMessageTool?: boolean;
|
disableMessageTool?: boolean;
|
||||||
}): AnyAgentTool[] {
|
}): AnyAgentTool[] {
|
||||||
const workspaceDir = options?.workspaceDir?.trim() || process.cwd();
|
const workspaceDir = resolveWorkspaceRoot(options?.workspaceDir);
|
||||||
const imageTool = options?.agentDir?.trim()
|
const imageTool = options?.agentDir?.trim()
|
||||||
? createImageTool({
|
? createImageTool({
|
||||||
config: options?.config,
|
config: options?.config,
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ export async function loadImageFromRef(
|
|||||||
const media = options?.sandbox
|
const media = options?.sandbox
|
||||||
? await loadWebMedia(targetPath, {
|
? await loadWebMedia(targetPath, {
|
||||||
maxBytes: options.maxBytes,
|
maxBytes: options.maxBytes,
|
||||||
localRoots: "any",
|
sandboxValidated: true,
|
||||||
readFile: (filePath) =>
|
readFile: (filePath) =>
|
||||||
options.sandbox!.bridge.readFile({ filePath, cwd: options.sandbox!.root }),
|
options.sandbox!.bridge.readFile({ filePath, cwd: options.sandbox!.root }),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
collectExplicitAllowlist,
|
collectExplicitAllowlist,
|
||||||
resolveToolProfilePolicy,
|
resolveToolProfilePolicy,
|
||||||
} from "./tool-policy.js";
|
} from "./tool-policy.js";
|
||||||
|
import { resolveWorkspaceRoot } from "./workspace-dir.js";
|
||||||
|
|
||||||
function isOpenAIProvider(provider?: string) {
|
function isOpenAIProvider(provider?: string) {
|
||||||
const normalized = provider?.trim().toLowerCase();
|
const normalized = provider?.trim().toLowerCase();
|
||||||
@@ -253,7 +254,7 @@ export function createOpenClawCodingTools(options?: {
|
|||||||
const sandboxRoot = sandbox?.workspaceDir;
|
const sandboxRoot = sandbox?.workspaceDir;
|
||||||
const sandboxFsBridge = sandbox?.fsBridge;
|
const sandboxFsBridge = sandbox?.fsBridge;
|
||||||
const allowWorkspaceWrites = sandbox?.workspaceAccess !== "ro";
|
const allowWorkspaceWrites = sandbox?.workspaceAccess !== "ro";
|
||||||
const workspaceRoot = options?.workspaceDir ?? process.cwd();
|
const workspaceRoot = resolveWorkspaceRoot(options?.workspaceDir);
|
||||||
const workspaceOnly = fsConfig.workspaceOnly === true;
|
const workspaceOnly = fsConfig.workspaceOnly === true;
|
||||||
const applyPatchConfig = execConfig.applyPatch;
|
const applyPatchConfig = execConfig.applyPatch;
|
||||||
// Secure by default: apply_patch is workspace-contained unless explicitly disabled.
|
// Secure by default: apply_patch is workspace-contained unless explicitly disabled.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { runWithImageModelFallback } from "../model-fallback.js";
|
|||||||
import { resolveConfiguredModelRef } from "../model-selection.js";
|
import { resolveConfiguredModelRef } from "../model-selection.js";
|
||||||
import { ensureOpenClawModelsJson } from "../models-config.js";
|
import { ensureOpenClawModelsJson } from "../models-config.js";
|
||||||
import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js";
|
import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js";
|
||||||
|
import { normalizeWorkspaceDir } from "../workspace-dir.js";
|
||||||
import {
|
import {
|
||||||
coerceImageAssistantText,
|
coerceImageAssistantText,
|
||||||
coerceImageModelConfig,
|
coerceImageModelConfig,
|
||||||
@@ -354,20 +355,11 @@ export function createImageTool(options?: {
|
|||||||
|
|
||||||
const localRoots = (() => {
|
const localRoots = (() => {
|
||||||
const roots = getDefaultLocalRoots();
|
const roots = getDefaultLocalRoots();
|
||||||
const workspaceDir = options?.workspaceDir?.trim();
|
const workspaceDir = normalizeWorkspaceDir(options?.workspaceDir);
|
||||||
if (!workspaceDir) {
|
if (!workspaceDir) {
|
||||||
return roots;
|
return roots;
|
||||||
}
|
}
|
||||||
const expanded = workspaceDir.startsWith("~") ? resolveUserPath(workspaceDir) : workspaceDir;
|
return Array.from(new Set([...roots, workspaceDir]));
|
||||||
const resolved = path.resolve(expanded);
|
|
||||||
// Defensive: never allow "/" as an implicit media root.
|
|
||||||
if (resolved === path.parse(resolved).root) {
|
|
||||||
return roots;
|
|
||||||
}
|
|
||||||
if (!roots.includes(resolved)) {
|
|
||||||
roots.push(resolved);
|
|
||||||
}
|
|
||||||
return roots;
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -460,7 +452,7 @@ export function createImageTool(options?: {
|
|||||||
: sandboxConfig
|
: sandboxConfig
|
||||||
? await loadWebMedia(resolvedPath ?? resolvedImage, {
|
? await loadWebMedia(resolvedPath ?? resolvedImage, {
|
||||||
maxBytes,
|
maxBytes,
|
||||||
localRoots: "any",
|
sandboxValidated: true,
|
||||||
readFile: (filePath) =>
|
readFile: (filePath) =>
|
||||||
sandboxConfig.bridge.readFile({ filePath, cwd: sandboxConfig.root }),
|
sandboxConfig.bridge.readFile({ filePath, cwd: sandboxConfig.root }),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { resolveUserPath } from "../utils.js";
|
||||||
|
|
||||||
|
export function normalizeWorkspaceDir(workspaceDir?: string): string | null {
|
||||||
|
const trimmed = workspaceDir?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const expanded = trimmed.startsWith("~") ? resolveUserPath(trimmed) : trimmed;
|
||||||
|
const resolved = path.resolve(expanded);
|
||||||
|
// Refuse filesystem roots as "workspace" (too broad; almost always a bug).
|
||||||
|
if (resolved === path.parse(resolved).root) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWorkspaceRoot(workspaceDir?: string): string {
|
||||||
|
return normalizeWorkspaceDir(workspaceDir) ?? process.cwd();
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type {
|
import type {
|
||||||
@@ -200,8 +201,12 @@ async function hydrateAttachmentPayload(params: {
|
|||||||
channel: params.channel,
|
channel: params.channel,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
// localRoots: "any" — media paths are already validated by normalizeSandboxMediaList above.
|
// mediaSource already validated by normalizeSandboxMediaList; allow bypass but force explicit readFile.
|
||||||
const media = await loadWebMedia(mediaSource, maxBytes, { localRoots: "any" });
|
const media = await loadWebMedia(mediaSource, {
|
||||||
|
maxBytes,
|
||||||
|
sandboxValidated: true,
|
||||||
|
readFile: (filePath: string) => fs.readFile(filePath),
|
||||||
|
});
|
||||||
params.args.buffer = media.buffer.toString("base64");
|
params.args.buffer = media.buffer.toString("base64");
|
||||||
if (!contentTypeParam && media.contentType) {
|
if (!contentTypeParam && media.contentType) {
|
||||||
params.args.contentType = media.contentType;
|
params.args.contentType = media.contentType;
|
||||||
|
|||||||
+13
-1
@@ -329,10 +329,22 @@ describe("local media root guard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("allows any path when localRoots is 'any'", async () => {
|
it("allows any path when localRoots is 'any'", async () => {
|
||||||
const result = await loadWebMedia(tinyPngFile, 1024 * 1024, { localRoots: "any" });
|
const result = await loadWebMedia(tinyPngFile, {
|
||||||
|
maxBytes: 1024 * 1024,
|
||||||
|
localRoots: "any",
|
||||||
|
readFile: (filePath) => fs.readFile(filePath),
|
||||||
|
});
|
||||||
expect(result.kind).toBe("image");
|
expect(result.kind).toBe("image");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects filesystem root entries in localRoots", async () => {
|
||||||
|
await expect(
|
||||||
|
loadWebMedia(tinyPngFile, 1024 * 1024, {
|
||||||
|
localRoots: [path.parse(tinyPngFile).root],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/refuses filesystem root/i);
|
||||||
|
});
|
||||||
|
|
||||||
it("allows default OpenClaw state workspace and sandbox roots", async () => {
|
it("allows default OpenClaw state workspace and sandbox roots", async () => {
|
||||||
const { STATE_DIR } = await import("../config/paths.js");
|
const { STATE_DIR } = await import("../config/paths.js");
|
||||||
const readFile = vi.fn(async () => Buffer.from("generated-media"));
|
const readFile = vi.fn(async () => Buffer.from("generated-media"));
|
||||||
|
|||||||
+23
-7
@@ -27,12 +27,14 @@ type WebMediaOptions = {
|
|||||||
maxBytes?: number;
|
maxBytes?: number;
|
||||||
optimizeImages?: boolean;
|
optimizeImages?: boolean;
|
||||||
ssrfPolicy?: SsrFPolicy;
|
ssrfPolicy?: SsrFPolicy;
|
||||||
/** Allowed root directories for local path reads. "any" skips the check (caller already validated). */
|
/** Allowed root directories for local path reads. "any" is deprecated; prefer sandboxValidated + readFile. */
|
||||||
localRoots?: string[] | "any";
|
localRoots?: readonly string[] | "any";
|
||||||
|
/** Caller already validated the local path (sandbox/other guards); requires readFile override. */
|
||||||
|
sandboxValidated?: boolean;
|
||||||
readFile?: (filePath: string) => Promise<Buffer>;
|
readFile?: (filePath: string) => Promise<Buffer>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getDefaultLocalRoots(): string[] {
|
export function getDefaultLocalRoots(): readonly string[] {
|
||||||
return [
|
return [
|
||||||
os.tmpdir(),
|
os.tmpdir(),
|
||||||
path.join(STATE_DIR, "media"),
|
path.join(STATE_DIR, "media"),
|
||||||
@@ -44,7 +46,7 @@ export function getDefaultLocalRoots(): string[] {
|
|||||||
|
|
||||||
async function assertLocalMediaAllowed(
|
async function assertLocalMediaAllowed(
|
||||||
mediaPath: string,
|
mediaPath: string,
|
||||||
localRoots: string[] | "any" | undefined,
|
localRoots: readonly string[] | "any" | undefined,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (localRoots === "any") {
|
if (localRoots === "any") {
|
||||||
return;
|
return;
|
||||||
@@ -64,6 +66,11 @@ async function assertLocalMediaAllowed(
|
|||||||
} catch {
|
} catch {
|
||||||
resolvedRoot = path.resolve(root);
|
resolvedRoot = path.resolve(root);
|
||||||
}
|
}
|
||||||
|
if (resolvedRoot === path.parse(resolvedRoot).root) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid localRoots entry (refuses filesystem root): ${root}. Pass a narrower directory.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep)) {
|
if (resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -173,6 +180,7 @@ async function loadWebMediaInternal(
|
|||||||
optimizeImages = true,
|
optimizeImages = true,
|
||||||
ssrfPolicy,
|
ssrfPolicy,
|
||||||
localRoots,
|
localRoots,
|
||||||
|
sandboxValidated = false,
|
||||||
readFile: readFileOverride,
|
readFile: readFileOverride,
|
||||||
} = options;
|
} = options;
|
||||||
// Strip MEDIA: prefix used by agent tools (e.g. TTS) to tag media paths.
|
// Strip MEDIA: prefix used by agent tools (e.g. TTS) to tag media paths.
|
||||||
@@ -275,8 +283,16 @@ async function loadWebMediaInternal(
|
|||||||
mediaUrl = resolveUserPath(mediaUrl);
|
mediaUrl = resolveUserPath(mediaUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((sandboxValidated || localRoots === "any") && !readFileOverride) {
|
||||||
|
throw new Error(
|
||||||
|
"Refusing localRoots bypass without readFile override. Use sandboxValidated with readFile, or pass explicit localRoots.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Guard local reads against allowed directory roots to prevent file exfiltration.
|
// Guard local reads against allowed directory roots to prevent file exfiltration.
|
||||||
await assertLocalMediaAllowed(mediaUrl, localRoots);
|
if (!(sandboxValidated || localRoots === "any")) {
|
||||||
|
await assertLocalMediaAllowed(mediaUrl, localRoots);
|
||||||
|
}
|
||||||
|
|
||||||
// Local path
|
// Local path
|
||||||
const data = readFileOverride ? await readFileOverride(mediaUrl) : await fs.readFile(mediaUrl);
|
const data = readFileOverride ? await readFileOverride(mediaUrl) : await fs.readFile(mediaUrl);
|
||||||
@@ -300,7 +316,7 @@ async function loadWebMediaInternal(
|
|||||||
export async function loadWebMedia(
|
export async function loadWebMedia(
|
||||||
mediaUrl: string,
|
mediaUrl: string,
|
||||||
maxBytesOrOptions?: number | WebMediaOptions,
|
maxBytesOrOptions?: number | WebMediaOptions,
|
||||||
options?: { ssrfPolicy?: SsrFPolicy; localRoots?: string[] | "any" },
|
options?: { ssrfPolicy?: SsrFPolicy; localRoots?: readonly string[] | "any" },
|
||||||
): Promise<WebMediaResult> {
|
): Promise<WebMediaResult> {
|
||||||
if (typeof maxBytesOrOptions === "number" || maxBytesOrOptions === undefined) {
|
if (typeof maxBytesOrOptions === "number" || maxBytesOrOptions === undefined) {
|
||||||
return await loadWebMediaInternal(mediaUrl, {
|
return await loadWebMediaInternal(mediaUrl, {
|
||||||
@@ -319,7 +335,7 @@ export async function loadWebMedia(
|
|||||||
export async function loadWebMediaRaw(
|
export async function loadWebMediaRaw(
|
||||||
mediaUrl: string,
|
mediaUrl: string,
|
||||||
maxBytesOrOptions?: number | WebMediaOptions,
|
maxBytesOrOptions?: number | WebMediaOptions,
|
||||||
options?: { ssrfPolicy?: SsrFPolicy; localRoots?: string[] | "any" },
|
options?: { ssrfPolicy?: SsrFPolicy; localRoots?: readonly string[] | "any" },
|
||||||
): Promise<WebMediaResult> {
|
): Promise<WebMediaResult> {
|
||||||
if (typeof maxBytesOrOptions === "number" || maxBytesOrOptions === undefined) {
|
if (typeof maxBytesOrOptions === "number" || maxBytesOrOptions === undefined) {
|
||||||
return await loadWebMediaInternal(mediaUrl, {
|
return await loadWebMediaInternal(mediaUrl, {
|
||||||
|
|||||||
Reference in New Issue
Block a user