mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(irc): guard surrogate-range codepoints in \u literal-escape decoder (#97683)
Two-step decode in decodeLiteralEscapes:
- Step 1: regex anchored to high-surrogate range (U+D800–U+DBFF) so a
preceding BMP escape (e.g. \u0041) cannot consume the high-surrogate
half of a valid pair like \uD83D\uDE00 (😀), leaving \uDE00 lone.
- Step 2: decode remaining BMP codepoints; preserve lone surrogates as
six-character literals instead of corrupting them to U+FFFD in the
outbound IRC UTF-8 stream.
This commit is contained in:
@@ -36,4 +36,52 @@ describe("irc protocol", () => {
|
||||
expect(() => sanitizeIrcTarget(" user")).toThrow(/Invalid IRC target/);
|
||||
});
|
||||
|
||||
describe("\\u escape surrogate-range guard", () => {
|
||||
const LONE_SURROGATE = /[\uD800-\uDFFF]/;
|
||||
|
||||
it("preserves literal \\uXXXX when codepoint is a high surrogate", () => {
|
||||
const out = sanitizeIrcOutboundText("\\uD800");
|
||||
expect(LONE_SURROGATE.test(out)).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves literal \\uXXXX when codepoint is a low surrogate", () => {
|
||||
const out = sanitizeIrcOutboundText("\\uDFFF");
|
||||
expect(LONE_SURROGATE.test(out)).toBe(false);
|
||||
});
|
||||
|
||||
it("still decodes valid BMP codepoints outside the surrogate range", () => {
|
||||
expect(sanitizeIrcOutboundText("\\u0041")).toBe("A");
|
||||
expect(sanitizeIrcOutboundText("\\u00e9")).toBe("é"); // é
|
||||
});
|
||||
|
||||
it("decodes adjacent surrogate-pair escapes to the astral character", () => {
|
||||
expect(sanitizeIrcOutboundText("\\uD83D\\uDE00")).toBe("😀");
|
||||
expect(sanitizeIrcOutboundText("\\uD83D\\uDE00\\uD83D\\uDE01")).toBe("😀😁");
|
||||
});
|
||||
|
||||
it("preserves lone high surrogate even when followed by a non-surrogate \\u", () => {
|
||||
const out = sanitizeIrcOutboundText("\\uD800\\u0041");
|
||||
expect(LONE_SURROGATE.test(out)).toBe(false);
|
||||
expect(out).toContain("A");
|
||||
});
|
||||
|
||||
it("decodes BMP-escaped prefix before a surrogate pair correctly", () => {
|
||||
// Regression: \\u0041\\uD83D\\uDE00 must yield A😀, not A\\uD83D\\uDE00.
|
||||
// The old step-1 regex \\u(xxxx)\\u(xxxx) would consume \\u0041\\uD83D as a
|
||||
// non-pair, leaving \\uDE00 as a lone surrogate.
|
||||
expect(sanitizeIrcOutboundText("\\u0041\\uD83D\\uDE00")).toBe("A😀");
|
||||
});
|
||||
|
||||
it("handles lone high surrogate followed by a different surrogate pair", () => {
|
||||
// \\uD800\\uD83D\\uDE00: D800 is lone (no matching low), D83D+DE00 form 😀.
|
||||
// Use toBe rather than LONE_SURROGATE regex: emoji contains surrogate
|
||||
// code units internally that would trigger a naive /[\uD800-\uDFFF]/ check.
|
||||
expect(sanitizeIrcOutboundText("\\uD800\\uD83D\\uDE00")).toBe("\\uD800😀");
|
||||
});
|
||||
|
||||
it("preserves two consecutive lone high surrogates", () => {
|
||||
const out = sanitizeIrcOutboundText("\\uD800\\uD801");
|
||||
expect(LONE_SURROGATE.test(out)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,13 +109,28 @@ export function parseIrcPrefix(prefix?: string): ParsedIrcPrefix {
|
||||
function decodeLiteralEscapes(input: string): string {
|
||||
// Defensive: this is not a full JS string unescaper.
|
||||
// It's just enough to catch common "\r\n" / "\u0001" style payloads.
|
||||
return input
|
||||
.replace(/\\r/g, "\r")
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\t/g, "\t")
|
||||
.replace(/\\0/g, "\0")
|
||||
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
||||
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
|
||||
return (
|
||||
input
|
||||
.replace(/\\r/g, "\r")
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\t/g, "\t")
|
||||
.replace(/\\0/g, "\0")
|
||||
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
||||
// Step 1: decode surrogate pairs — first group locked to high surrogate range
|
||||
// (U+D800–U+DBFF: [dD][89abAB]xx), second to low surrogate range
|
||||
// (U+DC00–U+DFFF: [dD][c-fC-F]xx). This prevents a non-surrogate \uXXXX
|
||||
// from being greedily paired with the following high surrogate and consuming it.
|
||||
.replace(/\\u([dD][89abAB][0-9a-fA-F]{2})\\u([dD][c-fC-F][0-9a-fA-F]{2})/g, (_match, h, l) =>
|
||||
String.fromCodePoint(
|
||||
0x10000 + ((Number.parseInt(h, 16) - 0xd800) << 10) + (Number.parseInt(l, 16) - 0xdc00),
|
||||
),
|
||||
)
|
||||
// Step 2: decode BMP codepoints; preserve lone surrogates as literals
|
||||
.replace(/\\u([0-9a-fA-F]{4})/g, (match, hex) => {
|
||||
const codePoint = Number.parseInt(hex, 16);
|
||||
return codePoint >= 0xd800 && codePoint <= 0xdfff ? match : String.fromCharCode(codePoint);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function sanitizeIrcOutboundText(text: string): string {
|
||||
|
||||
Reference in New Issue
Block a user