mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 10:42:43 +00:00
fix(security): fail-close node camera URL downloads
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { callGateway } = vi.hoisted(() => ({
|
||||
@@ -43,9 +44,15 @@ async function executeNodes(input: Record<string, unknown>) {
|
||||
type NodesToolResult = Awaited<ReturnType<typeof executeNodes>>;
|
||||
type GatewayMockResult = Record<string, unknown> | null | undefined;
|
||||
|
||||
function mockNodeList(commands?: string[]) {
|
||||
function mockNodeList(params?: { commands?: string[]; remoteIp?: string }) {
|
||||
return {
|
||||
nodes: [{ nodeId: NODE_ID, ...(commands ? { commands } : {}) }],
|
||||
nodes: [
|
||||
{
|
||||
nodeId: NODE_ID,
|
||||
...(params?.commands ? { commands: params.commands } : {}),
|
||||
...(params?.remoteIp ? { remoteIp: params.remoteIp } : {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,12 +73,13 @@ function expectFirstTextContains(result: NodesToolResult, expectedText: string)
|
||||
|
||||
function setupNodeInvokeMock(params: {
|
||||
commands?: string[];
|
||||
remoteIp?: string;
|
||||
onInvoke?: (invokeParams: unknown) => GatewayMockResult | Promise<GatewayMockResult>;
|
||||
invokePayload?: unknown;
|
||||
}) {
|
||||
callGateway.mockImplementation(async ({ method, params: invokeParams }: GatewayCall) => {
|
||||
if (method === "node.list") {
|
||||
return mockNodeList(params.commands);
|
||||
return mockNodeList({ commands: params.commands, remoteIp: params.remoteIp });
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
if (params.onInvoke) {
|
||||
@@ -108,7 +116,7 @@ function setupSystemRunGateway(params: {
|
||||
}) {
|
||||
callGateway.mockImplementation(async ({ method, params: gatewayParams }: GatewayCall) => {
|
||||
if (method === "node.list") {
|
||||
return mockNodeList(["system.run"]);
|
||||
return mockNodeList({ commands: ["system.run"] });
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
const command = (gatewayParams as { command?: string } | undefined)?.command;
|
||||
@@ -126,6 +134,7 @@ function setupSystemRunGateway(params: {
|
||||
|
||||
beforeEach(() => {
|
||||
callGateway.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("nodes camera_snap", () => {
|
||||
@@ -195,6 +204,116 @@ describe("nodes camera_snap", () => {
|
||||
}),
|
||||
).rejects.toThrow(/facing=both is not allowed when deviceId is set/i);
|
||||
});
|
||||
|
||||
it("downloads camera_snap url payloads when node remoteIp is available", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("url-image", { status: 200 })),
|
||||
);
|
||||
setupNodeInvokeMock({
|
||||
remoteIp: "198.51.100.42",
|
||||
invokePayload: {
|
||||
format: "jpg",
|
||||
url: "https://198.51.100.42/snap.jpg",
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeNodes({
|
||||
action: "camera_snap",
|
||||
node: NODE_ID,
|
||||
facing: "front",
|
||||
});
|
||||
|
||||
expect(result.content?.[0]).toMatchObject({ type: "text" });
|
||||
const mediaPath = String((result.content?.[0] as { text?: string } | undefined)?.text ?? "")
|
||||
.replace(/^MEDIA:/, "")
|
||||
.trim();
|
||||
try {
|
||||
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("url-image");
|
||||
} finally {
|
||||
await fs.unlink(mediaPath).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects camera_snap url payloads when node remoteIp is missing", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("url-image", { status: 200 })),
|
||||
);
|
||||
setupNodeInvokeMock({
|
||||
invokePayload: {
|
||||
format: "jpg",
|
||||
url: "https://198.51.100.42/snap.jpg",
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
executeNodes({
|
||||
action: "camera_snap",
|
||||
node: NODE_ID,
|
||||
facing: "front",
|
||||
}),
|
||||
).rejects.toThrow(/node remoteip/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nodes camera_clip", () => {
|
||||
it("downloads camera_clip url payloads when node remoteIp is available", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("url-clip", { status: 200 })),
|
||||
);
|
||||
setupNodeInvokeMock({
|
||||
remoteIp: "198.51.100.42",
|
||||
invokePayload: {
|
||||
format: "mp4",
|
||||
url: "https://198.51.100.42/clip.mp4",
|
||||
durationMs: 1200,
|
||||
hasAudio: false,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeNodes({
|
||||
action: "camera_clip",
|
||||
node: NODE_ID,
|
||||
facing: "front",
|
||||
});
|
||||
const filePath = String((result.content?.[0] as { text?: string } | undefined)?.text ?? "")
|
||||
.replace(/^FILE:/, "")
|
||||
.trim();
|
||||
try {
|
||||
await expect(fs.readFile(filePath, "utf8")).resolves.toBe("url-clip");
|
||||
} finally {
|
||||
await fs.unlink(filePath).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects camera_clip url payloads when node remoteIp is missing", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("url-clip", { status: 200 })),
|
||||
);
|
||||
setupNodeInvokeMock({
|
||||
invokePayload: {
|
||||
format: "mp4",
|
||||
url: "https://198.51.100.42/clip.mp4",
|
||||
durationMs: 1200,
|
||||
hasAudio: false,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
executeNodes({
|
||||
action: "camera_clip",
|
||||
node: NODE_ID,
|
||||
facing: "front",
|
||||
}),
|
||||
).rejects.toThrow(/node remoteip/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nodes notifications_list", () => {
|
||||
|
||||
@@ -28,7 +28,7 @@ import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
|
||||
import { sanitizeToolResultImages } from "../tool-images.js";
|
||||
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
||||
import { callGatewayTool, readGatewayCallOptions } from "./gateway.js";
|
||||
import { listNodes, resolveNodeIdFromList, resolveNodeId } from "./nodes-utils.js";
|
||||
import { listNodes, resolveNode, resolveNodeId, resolveNodeIdFromList } from "./nodes-utils.js";
|
||||
|
||||
const NODES_TOOL_ACTIONS = [
|
||||
"status",
|
||||
@@ -230,7 +230,8 @@ export function createNodesTool(options?: {
|
||||
}
|
||||
case "camera_snap": {
|
||||
const node = readStringParam(params, "node", { required: true });
|
||||
const nodeId = await resolveNodeId(gatewayOpts, node);
|
||||
const resolvedNode = await resolveNode(gatewayOpts, node);
|
||||
const nodeId = resolvedNode.nodeId;
|
||||
const facingRaw =
|
||||
typeof params.facing === "string" ? params.facing.toLowerCase() : "front";
|
||||
const facings: CameraFacing[] =
|
||||
@@ -295,7 +296,12 @@ export function createNodesTool(options?: {
|
||||
ext: isJpeg ? "jpg" : "png",
|
||||
});
|
||||
if (payload.url) {
|
||||
await writeUrlToFile(filePath, payload.url);
|
||||
if (!resolvedNode.remoteIp) {
|
||||
throw new Error("camera URL payload requires node remoteIp");
|
||||
}
|
||||
await writeUrlToFile(filePath, payload.url, {
|
||||
expectedHost: resolvedNode.remoteIp,
|
||||
});
|
||||
} else if (payload.base64) {
|
||||
await writeBase64ToFile(filePath, payload.base64);
|
||||
}
|
||||
@@ -373,7 +379,8 @@ export function createNodesTool(options?: {
|
||||
}
|
||||
case "camera_clip": {
|
||||
const node = readStringParam(params, "node", { required: true });
|
||||
const nodeId = await resolveNodeId(gatewayOpts, node);
|
||||
const resolvedNode = await resolveNode(gatewayOpts, node);
|
||||
const nodeId = resolvedNode.nodeId;
|
||||
const facing =
|
||||
typeof params.facing === "string" ? params.facing.toLowerCase() : "front";
|
||||
if (facing !== "front" && facing !== "back") {
|
||||
@@ -407,6 +414,7 @@ export function createNodesTool(options?: {
|
||||
const filePath = await writeCameraClipPayloadToFile({
|
||||
payload,
|
||||
facing,
|
||||
expectedHost: resolvedNode.remoteIp,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: `FILE:${filePath}` }],
|
||||
|
||||
@@ -160,6 +160,15 @@ export async function resolveNodeId(
|
||||
query?: string,
|
||||
allowDefault = false,
|
||||
) {
|
||||
const nodes = await loadNodes(opts);
|
||||
return resolveNodeIdFromList(nodes, query, allowDefault);
|
||||
return (await resolveNode(opts, query, allowDefault)).nodeId;
|
||||
}
|
||||
|
||||
export async function resolveNode(
|
||||
opts: GatewayCallOptions,
|
||||
query?: string,
|
||||
allowDefault = false,
|
||||
): Promise<NodeListNode> {
|
||||
const nodes = await loadNodes(opts);
|
||||
const nodeId = resolveNodeIdFromList(nodes, query, allowDefault);
|
||||
return nodes.find((node) => node.nodeId === nodeId) ?? { nodeId };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user