diff --git a/extensions/browser/src/browser/proxy-files.test.ts b/extensions/browser/src/browser/proxy-files.test.ts index 9632647ab8c4..d07dba232333 100644 --- a/extensions/browser/src/browser/proxy-files.test.ts +++ b/extensions/browser/src/browser/proxy-files.test.ts @@ -38,6 +38,30 @@ describe("persistBrowserProxyFiles", () => { await expect(fs.readFile(savedPath ?? "", "utf8")).resolves.toBe("hello from browser proxy"); }); + it("persists legitimate empty browser proxy downloads", async () => { + const sourcePath = "/tmp/empty-browser-download.bin"; + const mapping = await persistBrowserProxyFiles([ + { path: sourcePath, base64: "", mimeType: "application/octet-stream" }, + ]); + + const savedPath = mapping.get(sourcePath); + expect(typeof savedPath).toBe("string"); + await expect(fs.stat(savedPath ?? "")).resolves.toMatchObject({ size: 0 }); + await expect(fs.readFile(savedPath ?? "")).resolves.toHaveLength(0); + }); + + it.each([ + { name: "valid unpadded base64", base64: "aGVsbG8" }, + { name: "valid whitespace-separated base64", base64: " aG Vs bG8= \n" }, + ])("persists $name without corrupting the download", async ({ base64 }) => { + const sourcePath = "/tmp/normalized-browser-download.txt"; + const mapping = await persistBrowserProxyFiles([ + { path: sourcePath, base64, mimeType: "text/plain" }, + ]); + + await expect(fs.readFile(mapping.get(sourcePath) ?? "", "utf8")).resolves.toBe("hello"); + }); + it("persists a file at the proxy limit above the shared media default", async () => { const sourcePath = "/tmp/above-default.bin"; const buffer = Buffer.alloc(BROWSER_PROXY_MAX_FILE_BYTES, 0x41); @@ -104,6 +128,64 @@ describe("persistBrowserProxyFiles", () => { ).rejects.toHaveProperty("code", "ENOENT"); }); + it("rejects malformed base64 before persisting files", async () => { + const error = await persistBrowserProxyFiles([ + { + path: "/tmp/malformed.bin", + base64: "aGVsbG8$", + mimeType: "application/octet-stream", + }, + ]).then( + () => null, + (err: unknown) => err, + ); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("browser proxy file contains malformed base64 data"); + + await expect( + fs.stat(path.join(tempHome.home, ".openclaw", "media", "browser")), + ).rejects.toHaveProperty("code", "ENOENT"); + }); + + it.each([ + { name: "invalid alphabet", base64: "aGVsbG8$" }, + { name: "invalid padding", base64: "aGVsbG8===" }, + { name: "nonzero padding bits", base64: "ZE==" }, + { name: "impossible unpadded length", base64: "S" }, + { name: "whitespace without encoded data", base64: " \n\t" }, + ])("rejects $name before creating the media directory", async ({ base64 }) => { + await expect( + persistBrowserProxyFiles([ + { + path: "/tmp/malformed-browser-download.bin", + base64, + mimeType: "application/octet-stream", + }, + ]), + ).rejects.toThrow("browser proxy file contains malformed base64 data"); + + await expect( + fs.stat(path.join(tempHome.home, ".openclaw", "media", "browser")), + ).rejects.toHaveProperty("code", "ENOENT"); + }); + + it("rejects a later malformed file without persisting an earlier valid file", async () => { + await expect( + persistBrowserProxyFiles([ + { + path: "/tmp/valid-browser-download.txt", + base64: Buffer.from("valid browser download").toString("base64"), + mimeType: "text/plain", + }, + { path: "/tmp/malformed-browser-download.bin", base64: "ZE==" }, + ]), + ).rejects.toThrow("browser proxy file contains malformed base64 data"); + + await expect( + fs.stat(path.join(tempHome.home, ".openclaw", "media", "browser")), + ).rejects.toHaveProperty("code", "ENOENT"); + }); + it("rejects too many files before persisting any", async () => { const files = Array.from({ length: BROWSER_PROXY_MAX_FILES + 1 }, (_, index) => ({ path: `/tmp/file-${index}.bin`, diff --git a/extensions/browser/src/browser/proxy-files.ts b/extensions/browser/src/browser/proxy-files.ts index 3b0580f4faea..861be4282cce 100644 --- a/extensions/browser/src/browser/proxy-files.ts +++ b/extensions/browser/src/browser/proxy-files.ts @@ -4,6 +4,7 @@ * Persists files returned by node-hosted browser proxy calls and rewrites * proxied result paths to local saved media paths. */ +import { canonicalizeBase64, estimateBase64DecodedBytes } from "openclaw/plugin-sdk/media-runtime"; import { assertBrowserProxyFileCountWithinLimit, assertBrowserProxyFileBytesWithinLimits, @@ -13,6 +14,19 @@ import { } from "../browser-proxy-envelope.js"; import { saveMediaBuffer } from "../media/store.js"; +function decodeBrowserProxyFileBase64(file: BrowserProxyFile, totalBytes: number): Buffer { + const estimatedBytes = estimateBase64DecodedBytes(file.base64); + assertBrowserProxyFileBytesWithinLimits(estimatedBytes, totalBytes + estimatedBytes); + // The shared validator rejects empty input, but zero-byte downloads are valid files. + const canonicalBase64 = file.base64 === "" ? "" : canonicalizeBase64(file.base64); + if (canonicalBase64 === undefined) { + throw new Error("browser proxy file contains malformed base64 data"); + } + const buffer = Buffer.from(canonicalBase64, "base64"); + assertBrowserProxyFileBytesWithinLimits(buffer.byteLength, totalBytes + buffer.byteLength); + return buffer; +} + /** Persist proxy-returned files and return a remote-path to local-path map. */ export async function persistBrowserProxyFiles(files: BrowserProxyFile[] | undefined) { if (!files || files.length === 0) { @@ -22,9 +36,8 @@ export async function persistBrowserProxyFiles(files: BrowserProxyFile[] | undef const decoded: Array<{ file: BrowserProxyFile; buffer: Buffer }> = []; let totalBytes = 0; for (const file of files) { - const buffer = Buffer.from(file.base64, "base64"); + const buffer = decodeBrowserProxyFileBase64(file, totalBytes); totalBytes += buffer.byteLength; - assertBrowserProxyFileBytesWithinLimits(buffer.byteLength, totalBytes); decoded.push({ file, buffer }); }