fix (gateway): harden chat.send message input sanitization

This commit is contained in:
Vignesh Natarajan
2026-02-14 21:09:00 -08:00
parent 457e5308a9
commit a2fe3b6610
3 changed files with 85 additions and 4 deletions

View File

@@ -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é" });
});
});

View File

@@ -49,6 +49,27 @@ type TranscriptAppendResult = {
type AppendMessageArg = Parameters<SessionManager["appendMessage"]>[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,
});