diff --git a/src/agents/openclaw-tools.camera.test.ts b/src/agents/openclaw-tools.camera.test.ts index 8e45e7454a3c..55f9891113bd 100644 --- a/src/agents/openclaw-tools.camera.test.ts +++ b/src/agents/openclaw-tools.camera.test.ts @@ -420,16 +420,55 @@ describe("nodes photos_latest", () => { photo: { ...PHOTOS_LATEST_PAYLOAD.photos[0], format: "webp" }, expectedError: /unsupported photos\.latest format/i, }, + { + name: "malformed base64", + photo: { format: "jpeg", base64: "not-base64!", width: 1, height: 1 }, + expectedError: /invalid base64/i, + }, + { + name: "insecure URL", + photo: { format: "jpeg", url: "http://198.51.100.42/photo.jpg", width: 1, height: 1 }, + remoteIp: "198.51.100.42", + expectedError: /only https/i, + }, + { + name: "mismatched URL host", + photo: { format: "jpeg", url: "https://198.51.100.43/photo.jpg", width: 1, height: 1 }, + remoteIp: "198.51.100.42", + expectedError: /must match node host/i, + }, + { + name: "missing URL node host", + photo: { format: "jpeg", url: "https://198.51.100.42/photo.jpg", width: 1, height: 1 }, + expectedError: /node remoteip/i, + }, + { + name: "valid URL with malformed base64", + photo: { + format: "jpeg", + url: "https://198.51.100.42/photo.jpg", + base64: "not-base64!", + width: 1, + height: 1, + }, + remoteIp: "198.51.100.42", + expectedError: /invalid base64/i, + }, ])("rejects a second photo with $name before writing the first", async (testCase) => { + stubFetchTextResponse("url-image"); setupNodeInvokeMock({ + ...(testCase.remoteIp ? { remoteIp: testCase.remoteIp } : {}), invokePayload: { photos: [PHOTOS_LATEST_PAYLOAD.photos[0], testCase.photo] }, }); const firstPhotoId = "00000000-0000-4000-8000-000000000022"; + const secondPhotoId = "00000000-0000-4000-8000-000000000033"; const firstPhotoPath = cameraTempPath({ kind: "snap", ext: "jpg", id: firstPhotoId }); + const secondPhotoPath = cameraTempPath({ kind: "snap", ext: "jpg", id: secondPhotoId }); const randomUUID = vi .spyOn(crypto, "randomUUID") .mockReturnValueOnce("00000000-0000-4000-8000-000000000001") - .mockReturnValueOnce(firstPhotoId); + .mockReturnValueOnce(firstPhotoId) + .mockReturnValueOnce(secondPhotoId); try { await expect( @@ -446,6 +485,7 @@ describe("nodes photos_latest", () => { } finally { randomUUID.mockRestore(); await fs.unlink(firstPhotoPath).catch(() => undefined); + await fs.unlink(secondPhotoPath).catch(() => undefined); } }); diff --git a/src/agents/tools/nodes-tool-media.ts b/src/agents/tools/nodes-tool-media.ts index 4e8c3c453e84..7dd0ddbff5fe 100644 --- a/src/agents/tools/nodes-tool-media.ts +++ b/src/agents/tools/nodes-tool-media.ts @@ -198,7 +198,7 @@ async function executeCameraSnap({ }, idempotencyKey: crypto.randomUUID(), }); - const payload = parseCameraSnapPayload(raw?.payload); + const payload = parseCameraSnapPayload(raw?.payload, { expectedHost: resolvedNode.remoteIp }); const normalizedFormat = normalizeLowercaseStringOrEmpty(payload.format); if (normalizedFormat !== "jpg" && normalizedFormat !== "jpeg" && normalizedFormat !== "png") { throw new Error(`unsupported camera.snap format: ${payload.format}`); @@ -281,7 +281,7 @@ async function executePhotosLatest({ // Reject every malformed batch member before creating any capture artifact. const photos = payload.photos.map((photoRaw) => { - const photo = parseCameraSnapPayload(photoRaw); + const photo = parseCameraSnapPayload(photoRaw, { expectedHost: resolvedNode.remoteIp }); const normalizedFormat = normalizeLowercaseStringOrEmpty(photo.format); if (normalizedFormat !== "jpg" && normalizedFormat !== "jpeg" && normalizedFormat !== "png") { throw new Error(`unsupported photos.latest format: ${photo.format}`); diff --git a/src/cli/nodes-camera.test.ts b/src/cli/nodes-camera.test.ts index 469544b06520..c6c342c5db4b 100644 --- a/src/cli/nodes-camera.test.ts +++ b/src/cli/nodes-camera.test.ts @@ -156,6 +156,47 @@ describe("nodes camera helpers", () => { ); }); + it.each([ + { + name: "malformed base64", + payload: { format: "jpg", base64: "not-base64!", width: 1, height: 1 }, + expectedError: /invalid base64/i, + }, + { + name: "insecure URL", + payload: { format: "jpg", url: "http://198.51.100.42/photo.jpg", width: 1, height: 1 }, + expectedHost: "198.51.100.42", + expectedError: /only https/i, + }, + { + name: "mismatched URL host", + payload: { format: "jpg", url: "https://198.51.100.43/photo.jpg", width: 1, height: 1 }, + expectedHost: "198.51.100.42", + expectedError: /must match node host/i, + }, + { + name: "missing URL node host", + payload: { format: "jpg", url: "https://198.51.100.42/photo.jpg", width: 1, height: 1 }, + expectedError: /node remoteip/i, + }, + { + name: "valid URL with malformed base64", + payload: { + format: "jpg", + url: "https://198.51.100.42/photo.jpg", + base64: "not-base64!", + width: 1, + height: 1, + }, + expectedHost: "198.51.100.42", + expectedError: /invalid base64/i, + }, + ])("rejects $name while parsing a camera.snap payload", (testCase) => { + expect(() => + parseCameraSnapPayload(testCase.payload, { expectedHost: testCase.expectedHost }), + ).toThrow(testCase.expectedError); + }); + it.each([undefined, "front", "back", "both"] as const)( "collapses Linux facing=%s into one unknown-position capture", (facing) => { diff --git a/src/cli/nodes-camera.ts b/src/cli/nodes-camera.ts index bbbfb6b085d8..bf410128cecc 100644 --- a/src/cli/nodes-camera.ts +++ b/src/cli/nodes-camera.ts @@ -82,8 +82,11 @@ type CameraClipPayload = { hasAudio: boolean; }; -/** Validate and normalize an unknown camera still-image payload. */ -export function parseCameraSnapPayload(value: unknown): CameraSnapPayload { +/** Validate a complete still-image payload before any capture can be published. */ +export function parseCameraSnapPayload( + value: unknown, + opts: { expectedHost?: string } = {}, +): CameraSnapPayload { const obj = asRecord(value); const format = readStringValue(obj.format); const base64 = readStringValue(obj.base64); @@ -93,6 +96,12 @@ export function parseCameraSnapPayload(value: unknown): CameraSnapPayload { if (!format || (!base64 && !url) || width === undefined || height === undefined) { throw new Error("invalid camera.snap payload"); } + if (url) { + validateCameraPayloadUrl(url, requireNodeRemoteIp(opts.expectedHost)); + } + if (base64) { + validateCameraPayloadBase64(base64, MAX_CAMERA_BASE64_BYTES); + } return { format, ...(base64 ? { base64 } : {}), ...(url ? { url } : {}), width, height }; } @@ -128,21 +137,26 @@ export function cameraTempPath(opts: { return path.join(tmpDir, `${cliName}-camera-${opts.kind}${facingPart}-${id}${ext}`); } -/** Download a node-hosted media URL to disk after HTTPS, host, redirect, and size checks. */ -async function writeUrlToFile(filePath: string, url: string, opts: { expectedHost: string }) { +function validateCameraPayloadUrl(url: string, expectedNodeHost: string): string { const parsed = new URL(url); if (parsed.protocol !== "https:") { throw new Error(`writeUrlToFile: only https URLs are allowed, got ${parsed.protocol}`); } - const expectedHost = normalizeHostname(opts.expectedHost); + const expectedHost = normalizeHostname(expectedNodeHost); if (!expectedHost) { throw new Error("writeUrlToFile: expectedHost is required"); } if (normalizeHostname(parsed.hostname) !== expectedHost) { throw new Error( - `writeUrlToFile: url host ${parsed.hostname} must match node host ${opts.expectedHost}`, + `writeUrlToFile: url host ${parsed.hostname} must match node host ${expectedNodeHost}`, ); } + return expectedHost; +} + +/** Download a node-hosted media URL to disk after HTTPS, host, redirect, and size checks. */ +async function writeUrlToFile(filePath: string, url: string, opts: { expectedHost: string }) { + const expectedHost = validateCameraPayloadUrl(url, opts.expectedHost); // The node host is allowed even when private because the RPC response supplied its remote IP. const policy = { @@ -234,13 +248,7 @@ async function writeUrlToFile(filePath: string, url: string, opts: { expectedHos return { path: filePath, bytes }; } -/** Decode a base64 media payload to disk with preflight and post-decode size checks. */ -export async function writeBase64ToFile( - filePath: string, - base64: string, - opts: { maxBytes?: number } = {}, -) { - const maxBytes = opts.maxBytes ?? MAX_CAMERA_BASE64_BYTES; +function validateCameraPayloadBase64(base64: string, maxBytes: number): string { if (estimateBase64DecodedBytes(base64) > maxBytes) { throw new Error(`writeBase64ToFile: decoded payload exceeds max ${maxBytes}`); } @@ -248,6 +256,17 @@ export async function writeBase64ToFile( if (!canonicalBase64) { throw new Error("writeBase64ToFile: invalid base64 payload"); } + return canonicalBase64; +} + +/** Decode a base64 media payload to disk with preflight and post-decode size checks. */ +export async function writeBase64ToFile( + filePath: string, + base64: string, + opts: { maxBytes?: number } = {}, +) { + const maxBytes = opts.maxBytes ?? MAX_CAMERA_BASE64_BYTES; + const canonicalBase64 = validateCameraPayloadBase64(base64, maxBytes); const buf = Buffer.from(canonicalBase64, "base64"); if (buf.length > maxBytes) { throw new Error(`writeBase64ToFile: decoded ${buf.length} bytes, exceeds max ${maxBytes}`); diff --git a/src/cli/nodes-cli/register.camera.test.ts b/src/cli/nodes-cli/register.camera.test.ts index 424fb471c4ed..72a4155ac6ce 100644 --- a/src/cli/nodes-cli/register.camera.test.ts +++ b/src/cli/nodes-cli/register.camera.test.ts @@ -46,7 +46,7 @@ vi.mock("./rpc.js", async () => { return { payload: { format: "jpg", - base64: "redacted-base64", + base64: "cmVkYWN0ZWQtYmFzZTY0", width: 1600, height: 1200, }, diff --git a/src/cli/nodes-cli/register.camera.ts b/src/cli/nodes-cli/register.camera.ts index 8418404fe689..a3ad573fefc9 100644 --- a/src/cli/nodes-cli/register.camera.ts +++ b/src/cli/nodes-cli/register.camera.ts @@ -187,7 +187,9 @@ export function registerNodesCameraCommands(nodes: Command) { }); const raw = await callNodesGatewayCli("node.invoke", opts, invokeParams); - const payload = parseCameraSnapPayload(getGatewayInvokePayload(raw)); + const payload = parseCameraSnapPayload(getGatewayInvokePayload(raw), { + expectedHost: node.remoteIp, + }); const filePath = cameraTempPath({ kind: "snap", facing: target.artifactFacing,