mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-23 17:08:12 +00:00
CLI: bind node camera URL fetches to resolved node host
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string> {
|
||||
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 {
|
||||
|
||||
@@ -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 <idOrNameOrIp>", "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 <ms>", "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) {
|
||||
|
||||
@@ -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<NodeListNode> {
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user