mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 14:24:59 +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];
|
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: {
|
function resolveTranscriptPath(params: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
storePath: string | undefined;
|
storePath: string | undefined;
|
||||||
@@ -353,7 +374,17 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
idempotencyKey: string;
|
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 =
|
const normalizedAttachments =
|
||||||
p.attachments
|
p.attachments
|
||||||
?.map((a) => ({
|
?.map((a) => ({
|
||||||
@@ -372,7 +403,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
: undefined,
|
: undefined,
|
||||||
}))
|
}))
|
||||||
.filter((a) => a.content) ?? [];
|
.filter((a) => a.content) ?? [];
|
||||||
const rawMessage = p.message.trim();
|
const rawMessage = inboundMessage.trim();
|
||||||
if (!rawMessage && normalizedAttachments.length === 0) {
|
if (!rawMessage && normalizedAttachments.length === 0) {
|
||||||
respond(
|
respond(
|
||||||
false,
|
false,
|
||||||
@@ -381,11 +412,11 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let parsedMessage = p.message;
|
let parsedMessage = inboundMessage;
|
||||||
let parsedImages: ChatImageContent[] = [];
|
let parsedImages: ChatImageContent[] = [];
|
||||||
if (normalizedAttachments.length > 0) {
|
if (normalizedAttachments.length > 0) {
|
||||||
try {
|
try {
|
||||||
const parsed = await parseMessageWithAttachments(p.message, normalizedAttachments, {
|
const parsed = await parseMessageWithAttachments(inboundMessage, normalizedAttachments, {
|
||||||
maxBytes: 5_000_000,
|
maxBytes: 5_000_000,
|
||||||
log: context.logGateway,
|
log: context.logGateway,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,6 +48,36 @@ async function waitFor(condition: () => boolean, timeoutMs = 1500) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("gateway server chat", () => {
|
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 () => {
|
test("handles chat send and history flows", async () => {
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
let webchatWs: WebSocket | undefined;
|
let webchatWs: WebSocket | undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user