fix(canvas): validate snapshot response formats [AI] (#81881)

* fix: validate canvas snapshot formats

* addressing codex review

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-05-15 14:51:38 +05:30
committed by GitHub
parent e30be460e1
commit 238b0fc76f
6 changed files with 116 additions and 24 deletions

View File

@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- fix(canvas): validate snapshot response formats [AI]. (#81881) Thanks @pgondhi987.
- Constrain provider catalog entry paths [AI]. (#81884) Thanks @pgondhi987.
- Require canonical node platform IDs [AI]. (#81880) Thanks @pgondhi987.
- Agents/Azure OpenAI Responses: default unset Azure OpenAI API versions to `preview` so `/openai/v1/responses` calls use Azure's current Responses API route. (#82026) Thanks @leoge007.

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { parseCanvasSnapshotPayload } from "./cli-helpers.js";
import {
canvasSnapshotTempPath,
normalizeCanvasSnapshotFileExtension,
parseCanvasSnapshotPayload,
} from "./cli-helpers.js";
describe("canvas CLI helpers", () => {
it("parses canvas.snapshot payload", () => {
@@ -14,4 +18,45 @@ describe("canvas CLI helpers", () => {
/invalid canvas\.snapshot payload/i,
);
});
it.each([{ base64: "aGk=" }, { format: 42, base64: "aGk=" }])(
"rejects invalid canvas.snapshot format fields",
(payload) => {
expect(() => parseCanvasSnapshotPayload(payload)).toThrow(
/invalid canvas\.snapshot payload/i,
);
},
);
it.each(["/../../target.sh", "../target.sh", "png/../../target.sh", "image/png", ""])(
"rejects unsafe canvas.snapshot formats from responses: %s",
(format) => {
expect(() => parseCanvasSnapshotPayload({ format, base64: "aGk=" })).toThrow(
/invalid canvas\.snapshot payload/i,
);
},
);
it("normalizes supported snapshot file extensions", () => {
expect(normalizeCanvasSnapshotFileExtension("png")).toBe("png");
expect(normalizeCanvasSnapshotFileExtension(".jpeg")).toBe("jpg");
expect(normalizeCanvasSnapshotFileExtension(" JPG ")).toBe("jpg");
});
it("rejects unsafe snapshot temp path parts", () => {
expect(() =>
canvasSnapshotTempPath({
tmpDir: "/tmp/openclaw-canvas-test",
id: "snapshot",
ext: "/../../target.sh",
}),
).toThrow(/invalid canvas\.snapshot format/i);
expect(() =>
canvasSnapshotTempPath({
tmpDir: "/tmp/openclaw-canvas-test",
id: "../../snapshot",
ext: "png",
}),
).toThrow(/invalid canvas snapshot id/i);
});
});

View File

@@ -5,13 +5,32 @@ import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/security-run
import { asRecord, readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime";
type CanvasSnapshotPayload = {
format: string;
format: CanvasSnapshotFormat;
base64: string;
};
type CanvasSnapshotFormat = "png" | "jpg" | "jpeg";
type CanvasSnapshotFileExtension = "png" | "jpg";
function normalizeCanvasSnapshotFormat(value: string | undefined): CanvasSnapshotFormat | null {
const format = value?.trim().toLowerCase() ?? "";
if (format === "png" || format === "jpg" || format === "jpeg") {
return format;
}
return null;
}
export function normalizeCanvasSnapshotFileExtension(value: string): CanvasSnapshotFileExtension {
const format = normalizeCanvasSnapshotFormat(value.startsWith(".") ? value.slice(1) : value);
if (!format) {
throw new Error("invalid canvas.snapshot format");
}
return format === "jpeg" ? "jpg" : format;
}
export function parseCanvasSnapshotPayload(value: unknown): CanvasSnapshotPayload {
const obj = asRecord(value);
const format = readStringValue(obj.format);
const format = normalizeCanvasSnapshotFormat(readStringValue(obj.format));
const base64 = readStringValue(obj.base64);
if (!format || !base64) {
throw new Error("invalid canvas.snapshot payload");
@@ -23,6 +42,13 @@ function resolveCliName(): string {
return "openclaw";
}
function resolveCanvasSnapshotId(id: string): string {
if (!/^[A-Za-z0-9_-]+$/.test(id)) {
throw new Error("invalid canvas snapshot id");
}
return id;
}
function resolveTempPathParts(opts: { ext: string; tmpDir?: string; id?: string }) {
const tmpDir = opts.tmpDir ?? resolvePreferredOpenClawTmpDir();
if (!opts.tmpDir) {
@@ -30,8 +56,8 @@ function resolveTempPathParts(opts: { ext: string; tmpDir?: string; id?: string
}
return {
tmpDir,
id: opts.id ?? randomUUID(),
ext: opts.ext.startsWith(".") ? opts.ext : `.${opts.ext}`,
id: resolveCanvasSnapshotId(opts.id ?? randomUUID()),
ext: `.${normalizeCanvasSnapshotFileExtension(opts.ext)}`,
};
}

View File

@@ -91,4 +91,26 @@ describe("canvas CLI", () => {
expect(mediaMessage?.startsWith("MEDIA:")).toBe(true);
expect(mediaMessage?.endsWith(".png")).toBe(true);
});
it("rejects node-controlled snapshot formats before writing", async () => {
const program = new Command();
program.exitOverride();
const nodes = program.command("nodes");
const { deps, writtenFiles } = createCanvasCliDeps();
vi.mocked(deps.callGatewayCli).mockResolvedValueOnce({
payload: {
format: "/../../target.sh",
base64: "aGk=",
},
});
registerNodesCanvasCommands(nodes, deps);
await expect(
program.parseAsync(["nodes", "canvas", "snapshot", "--node", "ios-node"], {
from: "user",
}),
).rejects.toThrow(/invalid canvas\.snapshot payload/i);
expect(writtenFiles).toHaveLength(0);
});
});

View File

@@ -96,4 +96,19 @@ describe("Canvas tool", () => {
expect(imageResultParams?.details).toEqual({ format: "png" });
expect(imageResultParams?.imageSanitization).toEqual({ maxDimensionPx: 1600 });
});
it("rejects node-controlled snapshot formats before creating image results", async () => {
mocks.callGatewayTool.mockResolvedValue({
payload: {
format: "/../../target.sh",
base64: Buffer.from("not-a-real-png").toString("base64"),
},
});
const tool = createCanvasTool();
await expect(tool.execute("tool-call-1", { action: "snapshot" })).rejects.toThrow(
/invalid canvas\.snapshot payload/i,
);
expect(mocks.imageResultFromFile).not.toHaveBeenCalled();
});
});

View File

@@ -13,6 +13,7 @@ import {
} from "openclaw/plugin-sdk/channel-actions";
import type { AnyAgentTool, OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { normalizeCanvasSnapshotFileExtension, parseCanvasSnapshotPayload } from "./cli-helpers.js";
import { CanvasToolSchema } from "./tool-schema.js";
type CanvasToolOptions = {
@@ -20,11 +21,6 @@ type CanvasToolOptions = {
workspaceDir?: string;
};
type CanvasSnapshotPayload = {
format: string;
base64: string;
};
type CanvasImageSanitizationLimits = {
maxDimensionPx?: number;
};
@@ -45,23 +41,10 @@ async function resolveNodeId(
return resolveNodeIdFromList(await listNodes(opts), query, allowDefault);
}
function parseCanvasSnapshotPayload(value: unknown): CanvasSnapshotPayload {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("invalid canvas.snapshot payload");
}
const record = value as Record<string, unknown>;
const format = typeof record.format === "string" ? record.format : "";
const base64 = typeof record.base64 === "string" ? record.base64 : "";
if (!format || !base64) {
throw new Error("invalid canvas.snapshot payload");
}
return { format, base64 };
}
async function writeBase64ToTempFile(params: { base64: string; ext: string }): Promise<string> {
const dir = resolvePreferredOpenClawTmpDir();
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
const ext = params.ext.startsWith(".") ? params.ext : `.${params.ext}`;
const ext = `.${normalizeCanvasSnapshotFileExtension(params.ext)}`;
const filePath = path.join(dir, `openclaw-canvas-snapshot-${randomUUID()}${ext}`);
await fs.writeFile(filePath, Buffer.from(params.base64, "base64"));
return filePath;