fix(nodes): validate photo payloads before publishing captures (#116927)

This commit is contained in:
Peter Steinberger
2026-08-25 19:48:11 -07:00
committed by GitHub
parent 5e81a346d7
commit 6f2409b680
6 changed files with 120 additions and 18 deletions
+41 -1
View File
@@ -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);
}
});
+2 -2
View File
@@ -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}`);
+41
View File
@@ -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
View File
@@ -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}`);
+1 -1
View File
@@ -46,7 +46,7 @@ vi.mock("./rpc.js", async () => {
return {
payload: {
format: "jpg",
base64: "redacted-base64",
base64: "cmVkYWN0ZWQtYmFzZTY0",
width: 1600,
height: 1200,
},
+3 -1
View File
@@ -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,