mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
fix(nodes): validate photo payloads before publishing captures (#116927)
This commit is contained in:
committed by
GitHub
parent
5e81a346d7
commit
6f2409b680
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+32
-13
@@ -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}`);
|
||||
|
||||
@@ -46,7 +46,7 @@ vi.mock("./rpc.js", async () => {
|
||||
return {
|
||||
payload: {
|
||||
format: "jpg",
|
||||
base64: "redacted-base64",
|
||||
base64: "cmVkYWN0ZWQtYmFzZTY0",
|
||||
width: 1600,
|
||||
height: 1200,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user