mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 10:11:24 +00:00
fix (gateway): harden chat.send message input sanitization
This commit is contained in:
20
src/gateway/server-methods/chat.sanitize-message.test.ts
Normal file
20
src/gateway/server-methods/chat.sanitize-message.test.ts
Normal 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é" });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user