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.
This commit is contained in:
ToToKr
2026-07-29 15:11:40 +09:00
committed by GitHub
parent 5d0594d064
commit ac09e35bd3
2 changed files with 99 additions and 1 deletions
@@ -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<typeof import("openclaw/plugin-sdk/media-runtime")>(
"openclaw/plugin-sdk/media-runtime",
);
return {
...actual,
saveMediaBuffer: saveMediaBufferMock,
};
});
vi.mock("./client-adapter.js", async () => {
const actual = await vi.importActual<typeof import("./client-adapter.js")>("./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();
});
});
+6 -1
View File
@@ -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) ??