From ac09e35bd3321ef0e7a651cf8ca3b45e419702bf Mon Sep 17 00:00:00 2001 From: ToToKr Date: Wed, 29 Jul 2026 15:11:40 +0900 Subject: [PATCH] fix(signal): reject malformed base64 attachment data (#114883) signalRpcRequest returns the JSON-RPC result via a bare cast, so getAttachment data reaches Buffer.from unvalidated. Node drops out-of-alphabet characters instead of throwing, so a damaged payload was silently written to disk as a corrupted attachment. Canonicalize after the existing size guard and fail with the attachment id instead. --- .../src/monitor.attachment-base64.test.ts | 93 +++++++++++++++++++ extensions/signal/src/monitor.ts | 7 +- 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 extensions/signal/src/monitor.attachment-base64.test.ts diff --git a/extensions/signal/src/monitor.attachment-base64.test.ts b/extensions/signal/src/monitor.attachment-base64.test.ts new file mode 100644 index 000000000000..0dd9b67b0c3d --- /dev/null +++ b/extensions/signal/src/monitor.attachment-base64.test.ts @@ -0,0 +1,93 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SignalEventHandlerDeps } from "./monitor/event-handler.types.js"; + +const signalRpcRequestMock = vi.hoisted(() => vi.fn()); +const saveMediaBufferMock = vi.hoisted(() => vi.fn()); + +let capturedFetchAttachment: SignalEventHandlerDeps["fetchAttachment"] | undefined; + +vi.mock("openclaw/plugin-sdk/media-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/media-runtime", + ); + return { + ...actual, + saveMediaBuffer: saveMediaBufferMock, + }; +}); + +vi.mock("./client-adapter.js", async () => { + const actual = await vi.importActual("./client-adapter.js"); + return { + ...actual, + signalRpcRequest: signalRpcRequestMock, + }; +}); + +vi.mock("./monitor/event-handler.js", () => ({ + createSignalEventHandler: (deps: SignalEventHandlerDeps) => { + capturedFetchAttachment = deps.fetchAttachment; + return async () => {}; + }, +})); + +vi.mock("./signal-ingress.js", () => ({ + startSignalIngressMonitor: async () => ({ + receive: async () => {}, + stop: async () => {}, + }), +})); + +vi.mock("./sse-reconnect.js", () => ({ + runSignalSseLoop: async () => {}, +})); + +const { monitorSignalProvider } = await import("./monitor.js"); + +const config = { + channels: { + signal: { + transport: { kind: "external-native", url: "http://127.0.0.1:8080" }, + dmPolicy: "open", + allowFrom: ["*"], + }, + }, +} satisfies OpenClawConfig; + +function requireCapturedFetchAttachment(): SignalEventHandlerDeps["fetchAttachment"] { + if (!capturedFetchAttachment) { + throw new Error("expected monitor to configure fetchAttachment"); + } + return capturedFetchAttachment; +} + +describe("Signal attachment fetch", () => { + beforeEach(() => { + capturedFetchAttachment = undefined; + signalRpcRequestMock.mockReset(); + saveMediaBufferMock.mockReset().mockResolvedValue({ + path: "/tmp/signal-attachment.png", + contentType: "image/png", + }); + }); + + it("rejects malformed base64 attachment data", async () => { + signalRpcRequestMock.mockResolvedValue({ + data: "iVBORw0KGgoAAAANSUhE%%%UgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2Nk+M/wHwAF/gL+M6Q10QAAAABJRU5ErkJggg==", + }); + + await monitorSignalProvider({ config, autoStart: false }); + const fetchAttachment = requireCapturedFetchAttachment(); + + await expect( + fetchAttachment({ + baseUrl: "http://127.0.0.1:8080", + attachment: { id: "attachment-123", contentType: "image/png" }, + sender: "+15550001111", + maxBytes: 8 * 1024 * 1024, + }), + ).rejects.toThrow("Signal attachment attachment-123 returned malformed base64 data"); + expect(saveMediaBufferMock).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index 439b22abef9a..bf297446ef69 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -9,6 +9,7 @@ import type { SignalReactionNotificationMode, } from "openclaw/plugin-sdk/config-contracts"; import { + canonicalizeBase64, detectMime, estimateBase64DecodedBytes, saveMediaBuffer, @@ -315,7 +316,11 @@ async function fetchAttachment(params: { `Signal attachment ${attachment.id} exceeds ${(params.maxBytes / (1024 * 1024)).toFixed(0)}MB limit`, ); } - const buffer = Buffer.from(result.data, "base64"); + const canonicalData = canonicalizeBase64(result.data); + if (!canonicalData) { + throw new Error(`Signal attachment ${attachment.id} returned malformed base64 data`); + } + const buffer = Buffer.from(canonicalData, "base64"); const originalFilename = normalizeOptionalString(attachment.filename ?? undefined); const contentType = normalizeOptionalString(attachment.contentType ?? undefined) ??