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:
ZOOWH
2026-07-10 10:27:00 +08:00
committed by GitHub
parent e6a9a5d182
commit 7809acfc24
3 changed files with 44 additions and 32 deletions

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

View File

@@ -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. */

View File

@@ -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));