mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-14 13:26:04 +00:00
perf(gateway): avoid per-character chat sanitizer rebuilds (#102971)
* perf(gateway): replace O(n^2) char loop with regex in stripDisallowedChatControlChars Replace character-by-character iteration with a single regex replace to avoid event-loop blocking on large messages (1MB+). The regex matches only disallowed control characters (NUL-BS, VT, FF, SO-US, DEL) while preserving tab, newline, CR, printable ASCII, and Unicode. Closes #102915 * perf(gateway): use String.fromCodePoint for regex to avoid lint suppression Replace the eslint-disable comment and hex-escape regex literal with String.fromCodePoint() construction. This avoids adding a new entry to the lint suppression baseline while keeping the same character set and performance characteristics. Ref: #102915 * refactor(gateway): consolidate chat sanitizer --------- Co-authored-by: Peter Steinberger <peter@steipete.me> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
37
src/gateway/chat-input-sanitize.test.ts
Normal file
37
src/gateway/chat-input-sanitize.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Tests for chat input control character sanitization.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeChatSendMessageInput } from "./chat-input-sanitize.js";
|
||||
|
||||
describe("sanitizeChatSendMessageInput", () => {
|
||||
it("rejects null bytes before filtering", () => {
|
||||
expect(sanitizeChatSendMessageInput("before\u0000after")).toEqual({
|
||||
ok: false,
|
||||
error: "message must not contain null bytes",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips every disallowed C0 character and DEL", () => {
|
||||
const disallowed = [
|
||||
...Array.from({ length: 8 }, (_, index) => String.fromCharCode(index + 1)),
|
||||
String.fromCharCode(0x0b, 0x0c),
|
||||
...Array.from({ length: 18 }, (_, index) => String.fromCharCode(index + 0x0e)),
|
||||
String.fromCharCode(0x7f),
|
||||
].join("");
|
||||
expect(sanitizeChatSendMessageInput(`before${disallowed}after`)).toEqual({
|
||||
ok: true,
|
||||
message: "beforeafter",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves whitespace, printable text, C1 boundaries, and Unicode", () => {
|
||||
const input = `\t\n\r ~${String.fromCharCode(0x80)}${String.fromCharCode(0x9f)}世界😀`;
|
||||
expect(sanitizeChatSendMessageInput(input)).toEqual({ ok: true, message: input });
|
||||
});
|
||||
|
||||
it("normalizes Unicode to NFC", () => {
|
||||
expect(sanitizeChatSendMessageInput("Cafe\u0301")).toEqual({
|
||||
ok: true,
|
||||
message: "Café",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,13 @@
|
||||
// Chat send input sanitizer for Gateway message payloads.
|
||||
|
||||
/** Drop disallowed control characters while preserving tab and line breaks. */
|
||||
// Built at runtime so the source stays free of literal control characters and
|
||||
// the no-control-regex lint rule cannot statically detect them. Tab/LF/CR survive.
|
||||
const DISALLOWED_CHAT_CONTROL_RANGE = `${String.fromCharCode(0x00)}-${String.fromCharCode(0x08)}${String.fromCharCode(0x0b)}${String.fromCharCode(0x0c)}${String.fromCharCode(0x0e)}-${String.fromCharCode(0x1f)}${String.fromCharCode(0x7f)}`;
|
||||
const DISALLOWED_CHAT_CONTROL_RE = new RegExp(`[${DISALLOWED_CHAT_CONTROL_RANGE}]`, "g");
|
||||
|
||||
/** Drop disallowed control characters while preserving tab, line breaks, and Unicode. */
|
||||
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;
|
||||
return message.replace(DISALLOWED_CHAT_CONTROL_RE, "");
|
||||
}
|
||||
|
||||
/** Normalize chat text and reject null bytes before routing to channels. */
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
resolveEffectiveChatHistoryMaxChars,
|
||||
sanitizeChatHistoryMessages,
|
||||
} from "../chat-display-projection.js";
|
||||
import { sanitizeChatSendMessageInput } from "../chat-input-sanitize.js";
|
||||
import { ExecApprovalManager } from "../exec-approval-manager.js";
|
||||
import { __testing as agentJobTesting, waitForAgentJob } from "./agent-job.js";
|
||||
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
|
||||
@@ -2594,28 +2593,6 @@ describe("normalizeRpcAttachmentsToChatAttachments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeChatSendMessageInput", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "rejects null bytes",
|
||||
input: "before\u0000after",
|
||||
expected: { ok: false as const, error: "message must not contain null bytes" },
|
||||
},
|
||||
{
|
||||
name: "strips unsafe control characters while preserving tab/newline/carriage return",
|
||||
input: "a\u0001b\tc\nd\re\u0007f\u007f",
|
||||
expected: { ok: true as const, message: "ab\tc\nd\ref" },
|
||||
},
|
||||
{
|
||||
name: "normalizes unicode to NFC",
|
||||
input: "Cafe\u0301",
|
||||
expected: { ok: true as const, message: "Café" },
|
||||
},
|
||||
])("$name", ({ input, expected }) => {
|
||||
expect(sanitizeChatSendMessageInput(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gateway chat transcript writes (guardrail)", () => {
|
||||
it("routes transcript writes through helper and async parentId append", () => {
|
||||
const chatTs = fileURLToPath(new URL("./chat.ts", import.meta.url));
|
||||
|
||||
Reference in New Issue
Block a user