fix(browser): reject malformed proxy file base64 (#115045)

* fix(browser): reject malformed proxy file base64

* fix(browser): preserve empty proxy downloads

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
sunlit-deng
2026-07-29 18:08:01 +08:00
committed by GitHub
parent 4f31ba97c2
commit da8a2208d2
2 changed files with 97 additions and 2 deletions
@@ -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`,
+15 -2
View File
@@ -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 });
}