fix(security): fail-close node camera URL downloads

This commit is contained in:
Peter Steinberger
2026-03-02 23:23:30 +00:00
parent 7365aefa19
commit 3bf19d6f40
9 changed files with 302 additions and 74 deletions

View File

@@ -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", () => {

View File

@@ -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}` }],

View File

@@ -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 };
}