diff --git a/CHANGELOG.md b/CHANGELOG.md index a802411a94d..5b259dcc5ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,7 @@ Docs: https://docs.openclaw.ai - Security/Voice Call: harden `voice-call` telephony TTS override merging by blocking unsafe deep-merge keys (`__proto__`, `prototype`, `constructor`) and add regression coverage for top-level and nested prototype-pollution payloads. - Security/Windows Daemon: harden Scheduled Task `gateway.cmd` generation by quoting cmd metacharacter arguments, escaping `%`/`!` expansions, and rejecting CR/LF in arguments, descriptions, and environment assignments (`set "KEY=VALUE"`), preventing command injection in Windows daemon startup scripts. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Gateway/Canvas: replace shared-IP fallback auth with node-scoped session capability URLs for `/__openclaw__/canvas/*` and `/__openclaw__/a2ui/*`, fail closed when trusted-proxy requests omit forwarded client headers, and add IPv6/proxy-header regression coverage. This ships in the next npm release. Thanks @aether-ai-agent for reporting. +- Security/Canvas: restrict A2UI JSONL file reads to allowed local roots and reject non-local `jsonlPath` schemes to prevent unintended file exposure. Thanks @thewilloftheshadow. - Security/Net: enforce strict dotted-decimal IPv4 literals in SSRF checks and fail closed on unsupported legacy forms (octal/hex/short/packed, for example `0177.0.0.1`, `127.1`, `2130706433`) before DNS lookup. - Security/Discord: enforce trusted-sender guild permission checks for moderation actions (`timeout`, `kick`, `ban`) and ignore untrusted `senderUserId` params to prevent privilege escalation in tool-driven flows. Thanks @aether-ai-agent for reporting. - Security/ACP+Exec: add `openclaw acp --token-file/--password-file` secret-file support (with inline secret flag warnings), redact ACP working-directory prefixes to `~` home-relative paths, constrain exec script preflight file inspection to the effective `workdir` boundary, and add security-audit warnings when `tools.exec.host="sandbox"` is configured while sandbox mode is off. diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 41f059fb6a7..0169ca47172 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -107,7 +107,10 @@ export function createOpenClawTools(options?: { sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl, allowHostControl: options?.allowHostBrowserControl, }), - createCanvasTool({ config: options?.config }), + createCanvasTool({ + config: options?.config, + agentSessionKey: options?.agentSessionKey, + }), createNodesTool({ agentSessionKey: options?.agentSessionKey, config: options?.config, diff --git a/src/agents/tools/canvas-tool.ts b/src/agents/tools/canvas-tool.ts index 1e38192ec7c..ee452f83a87 100644 --- a/src/agents/tools/canvas-tool.ts +++ b/src/agents/tools/canvas-tool.ts @@ -1,10 +1,15 @@ import crypto from "node:crypto"; -import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { Type } from "@sinclair/typebox"; import { writeBase64ToFile } from "../../cli/nodes-camera.js"; import { canvasSnapshotTempPath, parseCanvasSnapshotPayload } from "../../cli/nodes-canvas.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { openFileWithinRoot, SafeOpenError } from "../../infra/fs-safe.js"; +import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { imageMimeFromFormat } from "../../media/mime.js"; +import { resolveUserPath } from "../../utils.js"; +import { resolveSessionAgentId } from "../agent-scope.js"; import { resolveImageSanitizationLimits } from "../image-sanitization.js"; import { optionalStringEnum, stringEnum } from "../schema/typebox.js"; import { type AnyAgentTool, imageResult, jsonResult, readStringParam } from "./common.js"; @@ -23,6 +28,79 @@ const CANVAS_ACTIONS = [ const CANVAS_SNAPSHOT_FORMATS = ["png", "jpg", "jpeg"] as const; +const PATH_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; +const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/; + +function resolveJsonlLocalPath(rawPath: string): string { + const trimmed = rawPath.trim(); + if (!trimmed) { + return trimmed; + } + if (trimmed.startsWith("file://")) { + try { + return fileURLToPath(trimmed); + } catch (err) { + throw new Error(`Invalid jsonlPath file URL: ${rawPath}`, { cause: err }); + } + } + if (PATH_SCHEME_RE.test(trimmed) && !WINDOWS_DRIVE_RE.test(trimmed)) { + throw new Error("jsonlPath must be a local file path."); + } + if (trimmed.startsWith("~")) { + return resolveUserPath(trimmed); + } + return path.resolve(trimmed); +} + +function resolveLocalRoot(filePath: string, roots: readonly string[]): string | null { + const resolvedPath = path.resolve(filePath); + for (const root of roots) { + const resolvedRoot = path.resolve(root); + const rel = path.relative(resolvedRoot, resolvedPath); + if (!rel || (!rel.startsWith("..") && !path.isAbsolute(rel))) { + return resolvedRoot; + } + } + return null; +} + +async function readJsonlFromPath(params: { + jsonlPath: string; + localRoots: readonly string[]; +}): Promise { + const resolvedPath = resolveJsonlLocalPath(params.jsonlPath); + const resolvedRoot = resolveLocalRoot(resolvedPath, params.localRoots); + if (!resolvedRoot) { + throw new Error("jsonlPath must be under an allowed directory."); + } + const relativePath = path.relative(resolvedRoot, resolvedPath); + try { + const opened = await openFileWithinRoot({ + rootDir: resolvedRoot, + relativePath, + }); + try { + const buffer = await opened.handle.readFile(); + return buffer.toString("utf8"); + } finally { + await opened.handle.close().catch(() => {}); + } + } catch (err) { + if (err instanceof SafeOpenError) { + if (err.code === "not-found") { + throw new Error("jsonlPath file not found.", { cause: err }); + } + if (err.code === "not-file") { + throw new Error("jsonlPath must be a regular file.", { cause: err }); + } + throw new Error("jsonlPath must be a regular file within an allowed directory.", { + cause: err, + }); + } + throw err; + } +} + // Flattened schema: runtime validates per-action requirements. const CanvasToolSchema = Type.Object({ action: stringEnum(CANVAS_ACTIONS), @@ -50,8 +128,15 @@ const CanvasToolSchema = Type.Object({ jsonlPath: Type.Optional(Type.String()), }); -export function createCanvasTool(options?: { config?: OpenClawConfig }): AnyAgentTool { +export function createCanvasTool(options?: { + config?: OpenClawConfig; + agentSessionKey?: string; +}): AnyAgentTool { const imageSanitization = resolveImageSanitizationLimits(options?.config); + const agentId = options?.agentSessionKey + ? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: options?.config }) + : undefined; + const localRoots = getAgentScopedMediaLocalRoots(options?.config ?? {}, agentId); return { label: "Canvas", name: "canvas", @@ -169,7 +254,10 @@ export function createCanvasTool(options?: { config?: OpenClawConfig }): AnyAgen typeof params.jsonl === "string" && params.jsonl.trim() ? params.jsonl : typeof params.jsonlPath === "string" && params.jsonlPath.trim() - ? await fs.readFile(params.jsonlPath.trim(), "utf8") + ? await readJsonlFromPath({ + jsonlPath: params.jsonlPath, + localRoots, + }) : ""; if (!jsonl.trim()) { throw new Error("jsonl or jsonlPath required");