security(cli): guard nodes-camera URL fetches with SSRF protections

This commit is contained in:
Vincent Koc
2026-02-22 16:01:58 -05:00
parent 9f508056d3
commit 7c85e8bd86
2 changed files with 57 additions and 47 deletions

View File

@@ -415,6 +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)
- 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.

View File

@@ -1,5 +1,6 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
import { resolveCliName } from "./cli-name.js";
import {
asBoolean,
@@ -78,58 +79,66 @@ export async function writeUrlToFile(filePath: string, url: string) {
throw new Error(`writeUrlToFile: only https URLs are allowed, got ${parsed.protocol}`);
}
const res = await fetch(url);
if (!res.ok) {
throw new Error(`failed to download ${url}: ${res.status} ${res.statusText}`);
}
const { response: res, release } = await fetchWithSsrFGuard({
url,
auditContext: "writeUrlToFile",
});
const contentLengthRaw = res.headers.get("content-length");
const contentLength = contentLengthRaw ? Number.parseInt(contentLengthRaw, 10) : undefined;
if (
typeof contentLength === "number" &&
Number.isFinite(contentLength) &&
contentLength > MAX_CAMERA_URL_DOWNLOAD_BYTES
) {
throw new Error(
`writeUrlToFile: content-length ${contentLength} exceeds max ${MAX_CAMERA_URL_DOWNLOAD_BYTES}`,
);
}
const body = res.body;
if (!body) {
throw new Error(`failed to download ${url}: empty response body`);
}
const fileHandle = await fs.open(filePath, "w");
let bytes = 0;
let thrown: unknown;
try {
const reader = body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (!value || value.byteLength === 0) {
continue;
}
bytes += value.byteLength;
if (bytes > MAX_CAMERA_URL_DOWNLOAD_BYTES) {
throw new Error(
`writeUrlToFile: downloaded ${bytes} bytes, exceeds max ${MAX_CAMERA_URL_DOWNLOAD_BYTES}`,
);
}
await fileHandle.write(value);
if (!res.ok) {
throw new Error(`failed to download ${url}: ${res.status} ${res.statusText}`);
}
} catch (err) {
thrown = err;
} finally {
await fileHandle.close();
}
if (thrown) {
await fs.unlink(filePath).catch(() => {});
throw thrown;
const contentLengthRaw = res.headers.get("content-length");
const contentLength = contentLengthRaw ? Number.parseInt(contentLengthRaw, 10) : undefined;
if (
typeof contentLength === "number" &&
Number.isFinite(contentLength) &&
contentLength > MAX_CAMERA_URL_DOWNLOAD_BYTES
) {
throw new Error(
`writeUrlToFile: content-length ${contentLength} exceeds max ${MAX_CAMERA_URL_DOWNLOAD_BYTES}`,
);
}
const body = res.body;
if (!body) {
throw new Error(`failed to download ${url}: empty response body`);
}
const fileHandle = await fs.open(filePath, "w");
let thrown: unknown;
try {
const reader = body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (!value || value.byteLength === 0) {
continue;
}
bytes += value.byteLength;
if (bytes > MAX_CAMERA_URL_DOWNLOAD_BYTES) {
throw new Error(
`writeUrlToFile: downloaded ${bytes} bytes, exceeds max ${MAX_CAMERA_URL_DOWNLOAD_BYTES}`,
);
}
await fileHandle.write(value);
}
} catch (err) {
thrown = err;
} finally {
await fileHandle.close();
}
if (thrown) {
await fs.unlink(filePath).catch(() => {});
throw thrown;
}
} finally {
await release();
}
return { path: filePath, bytes };