From bd3ee262e0da96fe9a8db43fa6332a677f0d0d62 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 23 Feb 2026 00:22:53 -0500 Subject: [PATCH] CLI: bind node camera URL fetches to resolved node host --- CHANGELOG.md | 2 +- src/cli/nodes-camera.test.ts | 21 ++++++++++++++++++ src/cli/nodes-camera.ts | 33 ++++++++++++++++++++++++++-- src/cli/nodes-cli/register.camera.ts | 11 ++++++---- src/cli/nodes-cli/rpc.ts | 7 +++++- 5 files changed, 66 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6829092afb0..82d3fdc26b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -415,7 +415,7 @@ Docs: https://docs.openclaw.ai - iOS/Chat: use a dedicated iOS chat session key for ChatSheet routing to avoid cross-client session collisions with main-session traffic. (#21139) thanks @mbelinky. - iOS/Chat: auto-resync chat history after reconnect sequence gaps, clear stale pending runs, and avoid dead-end manual refresh errors after transient disconnects. (#21135) thanks @mbelinky. - UI/Usage: reload usage data immediately when timezone changes so Local/UTC toggles apply the selected date range without requiring a manual refresh. (#17774) -- Security/CLI: route node camera payload URL downloads through `fetchWithSsrFGuard` to block private-network/metadata SSRF targets and unsafe redirects in `nodes-camera` flows. (#21145) +- Security/CLI: bind node camera payload URL downloads to the resolved node host/IP (with guarded redirect handling) so `nodes-camera` fetches cannot pivot to unrelated targets. (#21145) - iOS/Screen: move `WKWebView` lifecycle ownership into `ScreenWebView` coordinator and explicit attach/detach flow to reduce gesture/lifecycle crash risk (`__NSArrayM insertObject:atIndex:` paths) during screen tab updates. (#20366) Thanks @ngutman. - iOS/Onboarding: prevent pairing-status flicker during auto-resume by keeping resumed state transitions stable. (#20310) Thanks @mbelinky. - iOS/Onboarding: stabilize pairing and reconnect behavior by resetting stale pairing request state on manual retry, disconnecting both operator and node gateways on operator failure, and avoiding duplicate pairing loops from operator transport identity attachment. (#20056) Thanks @mbelinky. diff --git a/src/cli/nodes-camera.test.ts b/src/cli/nodes-camera.test.ts index bd78480fd78..cb3b6821155 100644 --- a/src/cli/nodes-camera.test.ts +++ b/src/cli/nodes-camera.test.ts @@ -132,6 +132,27 @@ describe("nodes camera helpers", () => { }); }); + it("enforces expected node host for url payloads", async () => { + stubFetchResponse(new Response("node-hosted", { status: 200 })); + await withCameraTempDir(async (dir) => { + const out = path.join(dir, "node.bin"); + await writeUrlToFile(out, "https://example.com/clip.mp4", { + expectedHost: "example.com", + }); + await expect(fs.readFile(out, "utf8")).resolves.toBe("node-hosted"); + }); + }); + + it("rejects url payload host mismatch against node host", async () => { + stubFetchResponse(new Response("mismatch", { status: 200 })); + await withCameraTempDir(async (dir) => { + const out = path.join(dir, "mismatch.bin"); + await expect( + writeUrlToFile(out, "https://example.com/clip.mp4", { expectedHost: "node.local" }), + ).rejects.toThrow(/must match node host/i); + }); + }); + it("rejects invalid url payload responses", async () => { const cases: Array<{ name: string; diff --git a/src/cli/nodes-camera.ts b/src/cli/nodes-camera.ts index c8e37774142..f778dc7c6fc 100644 --- a/src/cli/nodes-camera.ts +++ b/src/cli/nodes-camera.ts @@ -30,6 +30,14 @@ export type CameraClipPayload = { hasAudio: boolean; }; +function normalizeHostname(value: string): string { + const trimmed = value.trim().toLowerCase(); + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + return trimmed.slice(1, -1); + } + return trimmed; +} + export function parseCameraSnapPayload(value: unknown): CameraSnapPayload { const obj = asRecord(value); const format = asString(obj.format); @@ -73,15 +81,35 @@ export function cameraTempPath(opts: { return path.join(tmpDir, `${cliName}-camera-${opts.kind}${facingPart}-${id}${ext}`); } -export async function writeUrlToFile(filePath: string, url: string) { +export async function writeUrlToFile( + filePath: string, + url: string, + opts?: { expectedHost?: string }, +) { const parsed = new URL(url); if (parsed.protocol !== "https:") { throw new Error(`writeUrlToFile: only https URLs are allowed, got ${parsed.protocol}`); } + const expectedHost = opts?.expectedHost ? normalizeHostname(opts.expectedHost) : undefined; + if (expectedHost && normalizeHostname(parsed.hostname) !== expectedHost) { + throw new Error( + `writeUrlToFile: url host ${parsed.hostname} must match node host ${opts?.expectedHost}`, + ); + } + + const policy = + expectedHost !== undefined + ? { + allowPrivateNetwork: true, + allowedHostnames: [expectedHost], + hostnameAllowlist: [expectedHost], + } + : undefined; const { response: res, release } = await fetchWithSsrFGuard({ url, auditContext: "writeUrlToFile", + policy, }); let bytes = 0; @@ -155,6 +183,7 @@ export async function writeCameraClipPayloadToFile(params: { facing: CameraFacing; tmpDir?: string; id?: string; + expectedHost?: string; }): Promise { const filePath = cameraTempPath({ kind: "clip", @@ -164,7 +193,7 @@ export async function writeCameraClipPayloadToFile(params: { id: params.id, }); if (params.payload.url) { - await writeUrlToFile(filePath, params.payload.url); + await writeUrlToFile(filePath, params.payload.url, { expectedHost: params.expectedHost }); } else if (params.payload.base64) { await writeBase64ToFile(filePath, params.payload.base64); } else { diff --git a/src/cli/nodes-cli/register.camera.ts b/src/cli/nodes-cli/register.camera.ts index c93f63cf133..5129eedf7ec 100644 --- a/src/cli/nodes-cli/register.camera.ts +++ b/src/cli/nodes-cli/register.camera.ts @@ -13,7 +13,7 @@ import { } from "../nodes-camera.js"; import { parseDurationMs } from "../parse-duration.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; -import { buildNodeInvokeParams, callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; +import { buildNodeInvokeParams, callGatewayCli, nodesCallOpts, resolveNode } from "./rpc.js"; import type { NodesRpcOpts } from "./types.js"; const parseFacing = (value: string): CameraFacing => { @@ -36,7 +36,8 @@ export function registerNodesCameraCommands(nodes: Command) { .requiredOption("--node ", "Node id, name, or IP") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("camera list", async () => { - const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); + const node = await resolveNode(opts, String(opts.node ?? "")); + const nodeId = node.nodeId; const raw = await callGatewayCli( "node.invoke", opts, @@ -157,7 +158,7 @@ export function registerNodesCameraCommands(nodes: Command) { ext: payload.format === "jpeg" ? "jpg" : payload.format, }); if (payload.url) { - await writeUrlToFile(filePath, payload.url); + await writeUrlToFile(filePath, payload.url, { expectedHost: node.remoteIp }); } else if (payload.base64) { await writeBase64ToFile(filePath, payload.base64); } @@ -195,7 +196,8 @@ export function registerNodesCameraCommands(nodes: Command) { .option("--invoke-timeout ", "Node invoke timeout in ms (default 90000)", "90000") .action(async (opts: NodesRpcOpts & { audio?: boolean }) => { await runNodesCommand("camera clip", async () => { - const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); + const node = await resolveNode(opts, String(opts.node ?? "")); + const nodeId = node.nodeId; const facing = parseFacing(String(opts.facing ?? "front")); const durationMs = parseDurationMs(String(opts.duration ?? "3000")); const includeAudio = opts.audio !== false; @@ -223,6 +225,7 @@ export function registerNodesCameraCommands(nodes: Command) { const filePath = await writeCameraClipPayloadToFile({ payload, facing, + expectedHost: node.remoteIp, }); if (opts.json) { diff --git a/src/cli/nodes-cli/rpc.ts b/src/cli/nodes-cli/rpc.ts index 97719354772..8910e36d34b 100644 --- a/src/cli/nodes-cli/rpc.ts +++ b/src/cli/nodes-cli/rpc.ts @@ -73,6 +73,10 @@ export function unauthorizedHintForMessage(message: string): string | null { } export async function resolveNodeId(opts: NodesRpcOpts, query: string) { + return (await resolveNode(opts, query)).nodeId; +} + +export async function resolveNode(opts: NodesRpcOpts, query: string): Promise { const q = String(query ?? "").trim(); if (!q) { throw new Error("node required"); @@ -93,5 +97,6 @@ export async function resolveNodeId(opts: NodesRpcOpts, query: string) { remoteIp: n.remoteIp, })); } - return resolveNodeIdFromCandidates(nodes, q); + const nodeId = resolveNodeIdFromCandidates(nodes, q); + return nodes.find((node) => node.nodeId === nodeId) ?? { nodeId }; }