mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 16:24:58 +00:00
Security: restrict canvas jsonlPath file reads
This commit is contained in:
@@ -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/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/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/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/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/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.
|
- 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.
|
||||||
|
|||||||
@@ -107,7 +107,10 @@ export function createOpenClawTools(options?: {
|
|||||||
sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl,
|
sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl,
|
||||||
allowHostControl: options?.allowHostBrowserControl,
|
allowHostControl: options?.allowHostBrowserControl,
|
||||||
}),
|
}),
|
||||||
createCanvasTool({ config: options?.config }),
|
createCanvasTool({
|
||||||
|
config: options?.config,
|
||||||
|
agentSessionKey: options?.agentSessionKey,
|
||||||
|
}),
|
||||||
createNodesTool({
|
createNodesTool({
|
||||||
agentSessionKey: options?.agentSessionKey,
|
agentSessionKey: options?.agentSessionKey,
|
||||||
config: options?.config,
|
config: options?.config,
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import crypto from "node:crypto";
|
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 { Type } from "@sinclair/typebox";
|
||||||
import { writeBase64ToFile } from "../../cli/nodes-camera.js";
|
import { writeBase64ToFile } from "../../cli/nodes-camera.js";
|
||||||
import { canvasSnapshotTempPath, parseCanvasSnapshotPayload } from "../../cli/nodes-canvas.js";
|
import { canvasSnapshotTempPath, parseCanvasSnapshotPayload } from "../../cli/nodes-canvas.js";
|
||||||
import type { OpenClawConfig } from "../../config/config.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 { imageMimeFromFormat } from "../../media/mime.js";
|
||||||
|
import { resolveUserPath } from "../../utils.js";
|
||||||
|
import { resolveSessionAgentId } from "../agent-scope.js";
|
||||||
import { resolveImageSanitizationLimits } from "../image-sanitization.js";
|
import { resolveImageSanitizationLimits } from "../image-sanitization.js";
|
||||||
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
|
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
|
||||||
import { type AnyAgentTool, imageResult, jsonResult, readStringParam } from "./common.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 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<string> {
|
||||||
|
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.
|
// Flattened schema: runtime validates per-action requirements.
|
||||||
const CanvasToolSchema = Type.Object({
|
const CanvasToolSchema = Type.Object({
|
||||||
action: stringEnum(CANVAS_ACTIONS),
|
action: stringEnum(CANVAS_ACTIONS),
|
||||||
@@ -50,8 +128,15 @@ const CanvasToolSchema = Type.Object({
|
|||||||
jsonlPath: Type.Optional(Type.String()),
|
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 imageSanitization = resolveImageSanitizationLimits(options?.config);
|
||||||
|
const agentId = options?.agentSessionKey
|
||||||
|
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: options?.config })
|
||||||
|
: undefined;
|
||||||
|
const localRoots = getAgentScopedMediaLocalRoots(options?.config ?? {}, agentId);
|
||||||
return {
|
return {
|
||||||
label: "Canvas",
|
label: "Canvas",
|
||||||
name: "canvas",
|
name: "canvas",
|
||||||
@@ -169,7 +254,10 @@ export function createCanvasTool(options?: { config?: OpenClawConfig }): AnyAgen
|
|||||||
typeof params.jsonl === "string" && params.jsonl.trim()
|
typeof params.jsonl === "string" && params.jsonl.trim()
|
||||||
? params.jsonl
|
? params.jsonl
|
||||||
: typeof params.jsonlPath === "string" && params.jsonlPath.trim()
|
: typeof params.jsonlPath === "string" && params.jsonlPath.trim()
|
||||||
? await fs.readFile(params.jsonlPath.trim(), "utf8")
|
? await readJsonlFromPath({
|
||||||
|
jsonlPath: params.jsonlPath,
|
||||||
|
localRoots,
|
||||||
|
})
|
||||||
: "";
|
: "";
|
||||||
if (!jsonl.trim()) {
|
if (!jsonl.trim()) {
|
||||||
throw new Error("jsonl or jsonlPath required");
|
throw new Error("jsonl or jsonlPath required");
|
||||||
|
|||||||
Reference in New Issue
Block a user