fix(gateway): reject malformed artifact base64

This commit is contained in:
Vincent Koc
2026-06-20 08:33:27 +02:00
parent 3d05e973f0
commit bb7150de94
2 changed files with 197 additions and 17 deletions
@@ -756,6 +756,127 @@ describe("artifacts RPC handlers", () => {
expect(artifacts?.[0]).not.toHaveProperty("data");
});
it("treats malformed direct artifact data as unsupported downloads", async () => {
mockedMessages([
{
role: "assistant",
content: [
{
type: "file",
data: "not-base64!",
title: "bad.txt",
},
],
__openclaw: { seq: 6 },
},
]);
const { calls } = await listArtifacts({ sessionKey: "agent:main:main" });
const artifacts = expectArtifactList(calls).artifacts;
expect(artifacts).toHaveLength(1);
expectFields(artifacts?.[0], {
title: "bad.txt",
});
expectFields(artifacts?.[0]?.download, { mode: "unsupported" });
expect(artifacts?.[0]).not.toHaveProperty("data");
});
it("keeps unpadded direct artifact base64 downloadable", async () => {
mockedMessages([
{
role: "assistant",
content: [
{
type: "file",
data: "JVBERi0",
title: "report.pdf",
},
],
__openclaw: { seq: 7 },
},
]);
const listed = await listArtifacts({ sessionKey: "agent:main:main" });
const artifact = expectArtifactList(listed.calls).artifacts?.[0];
const artifactId = requireNonEmptyString(artifact?.id, "expected listed artifact id");
expectFields(artifact, {
title: "report.pdf",
sizeBytes: 5,
});
expectFields(artifact?.download, { mode: "bytes" });
const download = await downloadArtifact({
sessionKey: "agent:main:main",
artifactId,
});
const downloadPayload = expectOkPayload(download.calls) as Record<string, unknown>;
expectFields(downloadPayload, {
encoding: "base64",
data: "JVBERi0=",
});
});
it("treats malformed base64 data URLs as unsupported downloads", async () => {
mockedMessages([
{
role: "assistant",
content: [
{
type: "image",
image_url: "data:image/png;base64,not-base64!",
alt: "bad.png",
},
],
__openclaw: { seq: 7 },
},
]);
const { calls } = await listArtifacts({ sessionKey: "agent:main:main" });
const artifacts = expectArtifactList(calls).artifacts;
expect(artifacts).toHaveLength(1);
expectFields(artifacts?.[0], {
title: "bad.png",
});
expectFields(artifacts?.[0]?.download, { mode: "unsupported" });
expect(artifacts?.[0]).not.toHaveProperty("data");
});
it("keeps unpadded base64 data URLs downloadable", async () => {
mockedMessages([
{
role: "assistant",
content: [
{
type: "image",
image_url: "data:image/gif;base64,R0lGOD",
alt: "tiny.gif",
},
],
__openclaw: { seq: 8 },
},
]);
const listed = await listArtifacts({ sessionKey: "agent:main:main" });
const artifact = expectArtifactList(listed.calls).artifacts?.[0];
const artifactId = requireNonEmptyString(artifact?.id, "expected listed artifact id");
expectFields(artifact, {
title: "tiny.gif",
mimeType: "image/gif",
sizeBytes: 4,
});
expectFields(artifact?.download, { mode: "bytes" });
const download = await downloadArtifact({
sessionKey: "agent:main:main",
artifactId,
});
const downloadPayload = expectOkPayload(download.calls) as Record<string, unknown>;
expectFields(downloadPayload, {
encoding: "base64",
data: "R0lGOD==",
});
});
it("treats unsafe artifact URLs as unsupported downloads", async () => {
mockedMessages([
{
+76 -17
View File
@@ -51,6 +51,11 @@ type ArtifactCollectionOptions = {
downloadArtifactId?: string;
};
type ArtifactBase64Payload = {
data?: string;
sizeBytes: number;
};
type ResolvedArtifactSession = {
sessionKey: string;
agentId?: string;
@@ -155,40 +160,85 @@ function base64FromDataUrl(value: string): string | undefined {
if (!metadata.includes(";base64")) {
return undefined;
}
return trimmed.slice(commaIndex + 1).replace(/\s+/g, "");
return trimmed.slice(commaIndex + 1);
}
function isBase64Whitespace(value: string): boolean {
return value === " " || value === "\n" || value === "\r" || value === "\t";
}
function estimateBase64Size(value: string | undefined): number | undefined {
function isArtifactBase64DataChar(value: string): boolean {
const code = value.charCodeAt(0);
return (
(code >= 0x41 && code <= 0x5a) ||
(code >= 0x61 && code <= 0x7a) ||
(code >= 0x30 && code <= 0x39) ||
value === "+" ||
value === "/" ||
value === "-" ||
value === "_"
);
}
function normalizeArtifactBase64Char(value: string): string {
if (value === "-") {
return "+";
}
if (value === "_") {
return "/";
}
return value;
}
function readArtifactBase64Payload(
value: string | undefined,
opts: { includeData: boolean },
): ArtifactBase64Payload | undefined {
if (!value) {
return undefined;
}
let encodedLength = 0;
let padding = 0;
let sawPadding = false;
let data = opts.includeData ? "" : undefined;
for (const char of value) {
if (!char || isBase64Whitespace(char)) {
continue;
}
encodedLength += 1;
}
for (let index = value.length - 1; index >= 0 && padding < 2; index -= 1) {
const char = value[index];
if (!char || isBase64Whitespace(char)) {
if (isBase64Whitespace(char)) {
continue;
}
if (char === "=") {
padding += 1;
if (padding > 2) {
return undefined;
}
sawPadding = true;
encodedLength += 1;
if (data !== undefined) {
data += char;
}
continue;
}
break;
if (sawPadding || !isArtifactBase64DataChar(char)) {
return undefined;
}
encodedLength += 1;
if (data !== undefined) {
data += normalizeArtifactBase64Char(char);
}
}
if (encodedLength === 0) {
return undefined;
}
return Math.max(0, Math.floor((encodedLength * 3) / 4) - padding);
const remainder = encodedLength % 4;
if ((padding > 0 && remainder !== 0) || remainder === 1) {
return undefined;
}
if (data !== undefined && padding === 0 && remainder > 0) {
data += "=".repeat(4 - remainder);
}
return {
...(data !== undefined ? { data } : {}),
sizeBytes: Math.max(0, Math.floor((encodedLength * 3) / 4) - padding),
};
}
function mediaUrlValue(value: unknown): string | undefined {
@@ -274,10 +324,14 @@ function resolveBlockDownload(
const dataUrl = [url, sourceUrl, imageUrl, audioUrl, data, content, sourceData].find(
(value) => typeof value === "string" && /^data:/i.test(value),
);
const base64FromDetectedDataUrl = dataUrl ? base64FromDataUrl(dataUrl) : undefined;
const directBase64 = [data, sourceData, content].find(
(value) => typeof value === "string" && !/^data:/i.test(value),
const base64FromDetectedDataUrl = readArtifactBase64Payload(
dataUrl ? base64FromDataUrl(dataUrl) : undefined,
opts,
);
const directBase64 = [data, sourceData, content]
.filter((value): value is string => typeof value === "string" && !/^data:/i.test(value))
.map((value) => readArtifactBase64Payload(value, opts))
.find((value): value is ArtifactBase64Payload => value !== undefined);
const base64 = base64FromDetectedDataUrl ?? directBase64;
const remoteUrl = [url, sourceUrl, imageUrl, audioUrl].find(
(value) => typeof value === "string" && isSafeDownloadUrl(value),
@@ -292,9 +346,14 @@ function resolveBlockDownload(
const sizeBytes =
typeof explicitSize === "number" && Number.isFinite(explicitSize) && explicitSize >= 0
? Math.floor(explicitSize)
: estimateBase64Size(base64);
: base64?.sizeBytes;
if (base64) {
return { mode: "bytes", ...(opts.includeData ? { data: base64 } : {}), mimeType, sizeBytes };
return {
mode: "bytes",
...(base64.data ? { data: base64.data } : {}),
mimeType,
sizeBytes,
};
}
if (remoteUrl) {
return { mode: "url", url: remoteUrl, mimeType, sizeBytes };