fix(nodes): reject malformed media payloads when base64 is invalid (#104414)

* fix(nodes): reject malformed media base64 payloads

* fix(nodes): keep media base64 size preflight

* refactor(nodes): reuse shared base64 boundary

Co-authored-by: qingminlong <qing.minlong@xydigit.com>

* docs(changelog): note node media validation

* chore: leave changelog to release automation

* test(nodes): cover empty invalid media payloads

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
qingminlong
2026-07-11 10:11:09 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent 58ef98ec13
commit 183139f3ed
2 changed files with 25 additions and 10 deletions
+18 -2
View File
@@ -223,10 +223,10 @@ describe("nodes camera helpers", () => {
).rejects.toThrow(/node remoteip/i);
});
it("writes base64 to file", async () => {
it("normalizes valid base64 before writing", async () => {
await withCameraTempDir(async (dir) => {
const out = path.join(dir, "x.bin");
await writeBase64ToFile(out, "aGk=");
await writeBase64ToFile(out, " aGk\n");
await expect(readFileUtf8AndCleanup(out)).resolves.toBe("hi");
});
});
@@ -247,6 +247,22 @@ describe("nodes camera helpers", () => {
});
});
it("rejects empty and malformed base64 payloads before writing", async () => {
await withCameraTempDir(async (dir) => {
const out = path.join(dir, "x.bin");
for (const base64 of ["", " \n", "a", "a===", "not-base64!"]) {
await expect(writeBase64ToFile(out, base64)).rejects.toThrow(/invalid base64/i);
await expectPathMissing(out);
}
await expect(writeScreenRecordToFile(out, "not-base64!")).rejects.toThrow(/invalid base64/i);
await expectPathMissing(out);
await expect(writeScreenSnapshotToFile(out, "not-base64!")).rejects.toThrow(
/invalid base64/i,
);
await expectPathMissing(out);
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
+7 -8
View File
@@ -1,6 +1,7 @@
// Camera payload validation and artifact writers for node media commands.
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { canonicalizeBase64, estimateBase64DecodedBytes } from "@openclaw/media-core/base64";
import { toErrorObject } from "../infra/errors.js";
import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
import { normalizeHostname } from "../infra/net/hostname.js";
@@ -202,12 +203,6 @@ export async function writeUrlToFile(
return { path: filePath, bytes };
}
function estimateDecodedBase64Bytes(base64: string): number {
const normalized = base64.replace(/\s+/g, "");
const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
return Math.floor((normalized.length * 3) / 4) - padding;
}
/** Decode a base64 media payload to disk with preflight and post-decode size checks. */
export async function writeBase64ToFile(
filePath: string,
@@ -215,10 +210,14 @@ export async function writeBase64ToFile(
opts: { maxBytes?: number } = {},
) {
const maxBytes = opts.maxBytes ?? MAX_CAMERA_BASE64_BYTES;
if (estimateDecodedBase64Bytes(base64) > maxBytes) {
if (estimateBase64DecodedBytes(base64) > maxBytes) {
throw new Error(`writeBase64ToFile: decoded payload exceeds max ${maxBytes}`);
}
const buf = Buffer.from(base64, "base64");
const canonicalBase64 = canonicalizeBase64(base64);
if (!canonicalBase64) {
throw new Error("writeBase64ToFile: invalid base64 payload");
}
const buf = Buffer.from(canonicalBase64, "base64");
if (buf.length > maxBytes) {
throw new Error(`writeBase64ToFile: decoded ${buf.length} bytes, exceeds max ${maxBytes}`);
}