mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-23 10:28:11 +00:00
security(cli): guard nodes-camera URL fetches with SSRF protections
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user