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:
llagy009
2026-06-30 17:46:57 -07:00
committed by GitHub
parent fca15641db
commit 2b77862b92
2 changed files with 70 additions and 7 deletions
+48
View File
@@ -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);
});
});
});
+22 -7
View File
@@ -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+D800U+DBFF: [dD][89abAB]xx), second to low surrogate range
// (U+DC00U+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 {