diff --git a/CHANGELOG.md b/CHANGELOG.md index f9550212447..f125ce102f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai - Google Meet/Codex: report malformed node proxy `payloadJSON` responses with plugin-owned errors instead of leaking raw JSON parser failures. - Debug proxy: reject malformed relative-form proxy targets with a controlled 400 response instead of letting URL parsing escape the request handler. - File transfer: reject malformed inline `file_write` base64 before computing hashes or invoking paired nodes, avoiding Node's lenient base64 decoder. +- QA channel: skip malformed inline inbound attachment base64 instead of staging silently corrupted media for agent turns. - Models config/auth: stop inferring provider env-var markers from broad `^[A-Z_][A-Z0-9_]*$` strings, and resolve config-backed provider `apiKey` values only through structured env SecretRefs (`secrets.providers[id]` / `secrets.defaults`), so unrelated env vars cannot accidentally become provider credentials. Thanks @sallyom. - Media fetch: skip allocating and buffering the response body for bodyless media responses (HEAD probes and 204-style empty bodies), avoiding wasted heap on streams that carry no payload. Thanks @shakkernerd. - CLI/onboarding: forward provider-specific auth flags (e.g. `--openai-api-key`) through the onboarding wizard so they reach provider auth methods via `ctx.opts`, letting `--openai-api-key "$OPENAI_API_KEY"` skip the redundant "use existing env var?" prompt in non-interactive harnesses. (#81669) Thanks @sjf. diff --git a/extensions/qa-channel/src/inbound.test.ts b/extensions/qa-channel/src/inbound.test.ts index 4555b361684..12720d863f3 100644 --- a/extensions/qa-channel/src/inbound.test.ts +++ b/extensions/qa-channel/src/inbound.test.ts @@ -124,6 +124,31 @@ describe("handleQaInbound", () => { expect(ctxPayload?.SenderId).toBe("alice"); }); + it("skips malformed inline attachment base64 without dropping the message", async () => { + const runtime = createPluginRuntimeMock(); + setQaChannelRuntime(runtime); + + await handleQaInbound( + createQaInboundParams({ + message: { + attachments: [ + { + id: "attachment-1", + kind: "image", + mimeType: "image/png", + contentBase64: "AAA@@@", + }, + ], + }, + }), + ); + + expect(runtime.channel.turn.runAssembled).toHaveBeenCalledTimes(1); + const ctxPayload = firstRunAssembledParams(runtime).ctxPayload; + expect(ctxPayload.MediaPath).toBeUndefined(); + expect(ctxPayload.MediaPaths).toBeUndefined(); + }); + it("uses allowFrom as the group sender fallback for allowlist policy", async () => { const runtime = createPluginRuntimeMock(); setQaChannelRuntime(runtime); diff --git a/extensions/qa-channel/src/inbound.ts b/extensions/qa-channel/src/inbound.ts index c88c26e8655..4c7f175763e 100644 --- a/extensions/qa-channel/src/inbound.ts +++ b/extensions/qa-channel/src/inbound.ts @@ -19,6 +19,18 @@ export function isHttpMediaUrl(value: string): boolean { } } +function normalizeBase64ForCompare(value: string): string { + return value.replace(/=+$/u, "").replace(/-/gu, "+").replace(/_/gu, "/"); +} + +function decodeAttachmentBase64(value: string): Buffer | null { + const buffer = Buffer.from(value, "base64"); + if (normalizeBase64ForCompare(buffer.toString("base64")) !== normalizeBase64ForCompare(value)) { + return null; + } + return buffer; +} + async function resolveQaInboundMediaPayload(attachments: QaBusMessage["attachments"]) { if (!Array.isArray(attachments) || attachments.length === 0) { return {}; @@ -29,8 +41,13 @@ async function resolveQaInboundMediaPayload(attachments: QaBusMessage["attachmen continue; } if (typeof attachment.contentBase64 === "string" && attachment.contentBase64.trim()) { + const buffer = decodeAttachmentBase64(attachment.contentBase64); + if (!buffer) { + console.warn("[qa-channel] inbound attachment contentBase64 rejected (invalid base64)"); + continue; + } const saved = await saveMediaBuffer( - Buffer.from(attachment.contentBase64, "base64"), + buffer, attachment.mimeType, "inbound", undefined,