From a2fe3b66101c34f3e6880139330e411129a51db9 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 14 Feb 2026 21:09:00 -0800 Subject: [PATCH] fix (gateway): harden chat.send message input sanitization --- .../chat.sanitize-message.test.ts | 20 ++++++++++ src/gateway/server-methods/chat.ts | 39 +++++++++++++++++-- ...erver.chat.gateway-server-chat.e2e.test.ts | 30 ++++++++++++++ 3 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 src/gateway/server-methods/chat.sanitize-message.test.ts diff --git a/src/gateway/server-methods/chat.sanitize-message.test.ts b/src/gateway/server-methods/chat.sanitize-message.test.ts new file mode 100644 index 00000000000..dd41d4c883e --- /dev/null +++ b/src/gateway/server-methods/chat.sanitize-message.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeChatSendMessageInput } from "./chat.js"; + +describe("sanitizeChatSendMessageInput", () => { + it("rejects null bytes", () => { + expect(sanitizeChatSendMessageInput("before\u0000after")).toEqual({ + ok: false, + error: "message must not contain null bytes", + }); + }); + + it("strips unsafe control characters while preserving tab/newline/carriage return", () => { + const result = sanitizeChatSendMessageInput("a\u0001b\tc\nd\re\u0007f\u007f"); + expect(result).toEqual({ ok: true, message: "ab\tc\nd\ref" }); + }); + + it("normalizes unicode to NFC", () => { + expect(sanitizeChatSendMessageInput("Cafe\u0301")).toEqual({ ok: true, message: "Café" }); + }); +}); diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 1049f37e221..d82511386f8 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -49,6 +49,27 @@ type TranscriptAppendResult = { type AppendMessageArg = Parameters[0]; +function stripDisallowedChatControlChars(message: string): string { + let output = ""; + for (const char of message) { + const code = char.charCodeAt(0); + if (code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127)) { + output += char; + } + } + return output; +} + +export function sanitizeChatSendMessageInput( + message: string, +): { ok: true; message: string } | { ok: false; error: string } { + const normalized = message.normalize("NFC"); + if (normalized.includes("\u0000")) { + return { ok: false, error: "message must not contain null bytes" }; + } + return { ok: true, message: stripDisallowedChatControlChars(normalized) }; +} + function resolveTranscriptPath(params: { sessionId: string; storePath: string | undefined; @@ -353,7 +374,17 @@ export const chatHandlers: GatewayRequestHandlers = { timeoutMs?: number; idempotencyKey: string; }; - const stopCommand = isChatStopCommandText(p.message); + const sanitizedMessageResult = sanitizeChatSendMessageInput(p.message); + if (!sanitizedMessageResult.ok) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, sanitizedMessageResult.error), + ); + return; + } + const inboundMessage = sanitizedMessageResult.message; + const stopCommand = isChatStopCommandText(inboundMessage); const normalizedAttachments = p.attachments ?.map((a) => ({ @@ -372,7 +403,7 @@ export const chatHandlers: GatewayRequestHandlers = { : undefined, })) .filter((a) => a.content) ?? []; - const rawMessage = p.message.trim(); + const rawMessage = inboundMessage.trim(); if (!rawMessage && normalizedAttachments.length === 0) { respond( false, @@ -381,11 +412,11 @@ export const chatHandlers: GatewayRequestHandlers = { ); return; } - let parsedMessage = p.message; + let parsedMessage = inboundMessage; let parsedImages: ChatImageContent[] = []; if (normalizedAttachments.length > 0) { try { - const parsed = await parseMessageWithAttachments(p.message, normalizedAttachments, { + const parsed = await parseMessageWithAttachments(inboundMessage, normalizedAttachments, { maxBytes: 5_000_000, log: context.logGateway, }); diff --git a/src/gateway/server.chat.gateway-server-chat.e2e.test.ts b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts index 86f2e136676..3840c7ce3c1 100644 --- a/src/gateway/server.chat.gateway-server-chat.e2e.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts @@ -48,6 +48,36 @@ async function waitFor(condition: () => boolean, timeoutMs = 1500) { } describe("gateway server chat", () => { + test("sanitizes inbound chat.send message text and rejects null bytes", async () => { + const nullByteRes = await rpcReq(ws, "chat.send", { + sessionKey: "main", + message: "hello\u0000world", + idempotencyKey: "idem-null-byte-1", + }); + expect(nullByteRes.ok).toBe(false); + expect((nullByteRes.error as { message?: string } | undefined)?.message ?? "").toMatch( + /null bytes/i, + ); + + const spy = vi.mocked(getReplyFromConfig); + spy.mockClear(); + const callsBeforeSanitized = spy.mock.calls.length; + const sanitizedRes = await rpcReq(ws, "chat.send", { + sessionKey: "main", + message: "Cafe\u0301\u0007\tline", + idempotencyKey: "idem-sanitized-1", + }); + expect(sanitizedRes.ok).toBe(true); + + await waitFor(() => spy.mock.calls.length > callsBeforeSanitized); + const ctx = spy.mock.calls.at(-1)?.[0] as + | { Body?: string; RawBody?: string; BodyForCommands?: string } + | undefined; + expect(ctx?.Body).toBe("Café\tline"); + expect(ctx?.RawBody).toBe("Café\tline"); + expect(ctx?.BodyForCommands).toBe("Café\tline"); + }); + test("handles chat send and history flows", async () => { const tempDirs: string[] = []; let webchatWs: WebSocket | undefined;