From d7dbaab2fe4b63c7a51d3393d10b216cbc458a11 Mon Sep 17 00:00:00 2001 From: Sean Doherty Date: Sat, 16 May 2026 16:44:31 -0500 Subject: [PATCH 1/3] harden formatter regex replacements --- .../formatter/src/errorMessagePrettifier.ts | 96 +++++++++++++++---- packages/formatter/test/formatter.vitest.ts | 16 ++++ 2 files changed, 94 insertions(+), 18 deletions(-) diff --git a/packages/formatter/src/errorMessagePrettifier.ts b/packages/formatter/src/errorMessagePrettifier.ts index 952687e..ce1bdb4 100644 --- a/packages/formatter/src/errorMessagePrettifier.ts +++ b/packages/formatter/src/errorMessagePrettifier.ts @@ -13,7 +13,13 @@ export function createErrorMessagePrettifier( const rules = await getRules(codeBlock); let output = message; - for (const { pattern, replacer } of rules) { + for (const rule of rules) { + if ("replace" in rule) { + output = await rule.replace(output); + continue; + } + + const { pattern, replacer } = rule; let result = ""; let lastIndex = 0; for (const match of output.matchAll(pattern)) { @@ -30,10 +36,63 @@ export function createErrorMessagePrettifier( }; } -type Rule = { - pattern: RegExp; - replacer: (...args: any[]) => string | Promise; -}; +type Rule = + | { + pattern: RegExp; + replacer: (...args: any[]) => string | Promise; + } + | { + replace: (message: string) => string | Promise; + }; + +async function replaceQuotedStringLiteralTypes( + output: string, + codeBlock: CodeBlockFn +): Promise { + let result = ""; + let lastIndex = 0; + let searchIndex = 0; + + while (searchIndex < output.length) { + let startIndex = -1; + for (let i = searchIndex; i < output.length - 2; i++) { + if ( + /\s/.test(output.charAt(i)) && + output.charAt(i + 1) === "'" && + output.charAt(i + 2) === '"' + ) { + startIndex = i; + break; + } + } + + if (startIndex === -1) break; + + const contentStart = startIndex + 3; + let endIndex = contentStart; + while (true) { + endIndex = output.indexOf(`"'`, endIndex); + if (endIndex === -1) break; + if (output[endIndex - 1] !== "\\") break; + endIndex += 1; + } + + if (endIndex === -1) break; + + result += output.slice(lastIndex, startIndex); + result += await formatTypeBlock( + "", + `"${output.slice(contentStart, endIndex)}"`, + codeBlock + ); + + const matchEnd = endIndex + 2; + lastIndex = matchEnd < output.length ? matchEnd + 1 : matchEnd; + searchIndex = lastIndex; + } + + return result + output.slice(lastIndex); +} async function getRules(codeBlock: CodeBlockFn): Promise { const formatTypeScriptBlock = (code: string) => codeBlock(code, "typescript"); @@ -51,17 +110,17 @@ async function getRules(codeBlock: CodeBlockFn): Promise { return [ { - pattern: /(?:\s)'"(.*?)(? formatTypeBlock("", `"${p1}"`, codeBlock), + replace: (output: string) => + replaceQuotedStringLiteralTypes(output, codeBlock), }, { - pattern: /['“](declare module )['”](.*)['“];['”]/g, + pattern: /['“](declare module )['”]([^'“”\r\n]*)['“];['”]/g, replacer: (p1: string, p2: string) => formatTypeScriptBlock(`${p1} "${p2}"`), }, { pattern: - /(is missing the following properties from type\s?)'(.*)': ((?:#?\w+, )*(?:(?!and)\w+)?)/g, + /(is missing the following properties from type\s?)'([^'\r\n]*)': ((?:#?\w+, )*(?:(?!and)\w+)?)/g, replacer: async (pre: string, type: string, post: string) => { const formattedType = await formatTypeBlock("", type, codeBlock); const list = post @@ -73,7 +132,7 @@ async function getRules(codeBlock: CodeBlockFn): Promise { }, }, { - pattern: /(types) ['“](.*?)['”] and ['“](.*?)['”][.]?/gi, + pattern: /(types) ['“]([^'“”\r\n]*)['”] and ['“]([^'“”\r\n]*)['”][.]?/gi, replacer: async (p1: string, p2: string, p3: string) => { const [left, right] = await Promise.all([ formatTypeBlock(p1, p2, codeBlock), @@ -83,7 +142,8 @@ async function getRules(codeBlock: CodeBlockFn): Promise { }, }, { - pattern: /type annotation must be ['“](.*?)['”] or ['“](.*?)['”][.]?/gi, + pattern: + /type annotation must be ['“]([^'“”\r\n]*)['”] or ['“]([^'“”\r\n]*)['”][.]?/gi, replacer: async (p1: string, p2: string, p3: string | number) => { if (typeof p3 === "string") { const [left, right] = await Promise.all([ @@ -100,7 +160,7 @@ async function getRules(codeBlock: CodeBlockFn): Promise { }, }, { - pattern: /(Overload \d of \d), ['“](.*?)['”], /gi, + pattern: /(Overload \d of \d), ['“]([^'“”\r\n]*)['”], /gi, replacer: async (p1: string, p2: string) => `${p1}${await formatTypeBlock("", p2, codeBlock)}`, }, @@ -114,18 +174,18 @@ async function getRules(codeBlock: CodeBlockFn): Promise { }, { pattern: - /(module|file|file name|imported via) ['"“](.*?)['"“](?=[\s(.|,]|$)/gi, + /(module|file|file name|imported via) ['"“]([^'"“”\r\n]*)['"“](?=[\s(.|,]|$)/gi, replacer: async (p1: string, p2: string) => formatTypeBlock(p1, `"${p2}"`, codeBlock), }, { pattern: - /(type|type alias|interface|module|file|file name|class|method's|subtype of constraint) ['“](.*?)['“](?=[\s(.|,)]|$)/gi, + /\b(type|type alias|interface|module|file|file name|class|method's|subtype of constraint) ['“]((?:[^'“\r\n]|['“](?![\s(.|,)]|$))*)['“](?=[\s(.|,)]|$)/gi, replacer: (p1: string, p2: string) => formatTypeOrModuleBlock(p1, p2), }, { pattern: - /['“]([^>]*)['”] (type|interface|return type|file|module|is (not )?assignable)/gi, + /['“]([^'“”>\r\n]*)['”] (type|interface|return type|file|module|is (not )?assignable)/gi, replacer: async (p1: string, p2: string) => `${await formatTypeOrModuleBlock("", p1)} ${p2}`, }, @@ -136,16 +196,16 @@ async function getRules(codeBlock: CodeBlockFn): Promise { }, { pattern: - /['“](import|export|require|in|continue|break|let|false|true|const|new|throw|await|for await|[0-9]+)( ?.*?)['”]/g, + /['“](import|export|require|in|continue|break|let|false|true|const|new|throw|await|for await|[0-9]+)( ?[^'“”\r\n]*)['”]/g, replacer: (p1: string, p2: string) => formatTypeScriptBlock(`${p1}${p2}`), }, { - pattern: /(return|operator) ['“](.*?)['”]/gi, + pattern: /(return|operator) ['“]([^'“”\r\n]*)['”]/gi, replacer: (p1: string, p2: string) => `${p1} ${formatTypeScriptBlock(p2)}`, }, { - pattern: /(? ` ${codeBlock(p1)} `, }, ]; diff --git a/packages/formatter/test/formatter.vitest.ts b/packages/formatter/test/formatter.vitest.ts index 7266401..2e73a84 100644 --- a/packages/formatter/test/formatter.vitest.ts +++ b/packages/formatter/test/formatter.vitest.ts @@ -27,6 +27,22 @@ describe("formatter", (context) => { ); }); + it("handles adversarial formatter patterns without catastrophic backtracking", async () => { + const repeat = 5_000; + const messages = [ + "\\" + "\t'\"\t".repeat(repeat) + "\n", + "is missing the following properties from type's".repeat(repeat) + "\n", + "TYPE ANNOTATION MUST BE “".repeat(repeat) + "'", + "OVERLOAD 0 OF 0, “".repeat(repeat) + "\nOVERLOAD 0 OF 0, '', ", + "E" + "MTYPE 'R".repeat(repeat) + "\n", + "'" + "'0'0\x00".repeat(repeat) + "\n", + ]; + + for (const message of messages) { + await expect(prettifyErrorMessage(message)).resolves.toBeTypeOf("string"); + } + }, 2_000); + it("formats Special characters in object keys", async () => { expect(await prettifyErrorMessage(errorWithSpecialCharsInObjectKeys)).toBe( 'Type `string` is not assignable to type `{ "abc*bc": string }`.' From d2c3e42d24941aee72537f89d69450a8581260d0 Mon Sep 17 00:00:00 2001 From: Sean Doherty Date: Sat, 16 May 2026 17:34:11 -0500 Subject: [PATCH 2/3] Harden missing-parentheses formatter --- .../formatter/src/addMissingParentheses.ts | 37 +++++++++++++++++-- packages/formatter/test/formatter.vitest.ts | 10 +++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/formatter/src/addMissingParentheses.ts b/packages/formatter/src/addMissingParentheses.ts index fc65a00..bfd2028 100644 --- a/packages/formatter/src/addMissingParentheses.ts +++ b/packages/formatter/src/addMissingParentheses.ts @@ -9,6 +9,37 @@ const parentheses = { const openParentheses = objectKeys(parentheses); const closeParentheses = Object.values(parentheses); +function isAsciiAlphaNumeric(char: string): boolean { + const code = char.charCodeAt(0); + return ( + (code >= 48 && code <= 57) || + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) + ); +} + +function addMissingFunctionReturnType(type: string): string { + for (let openIndex = 0; openIndex < type.length; openIndex++) { + if (type[openIndex] !== "(") continue; + + let colonIndex = openIndex + 1; + while (colonIndex < type.length && isAsciiAlphaNumeric(type[colonIndex]!)) { + colonIndex++; + } + + if (type[colonIndex] !== ":") continue; + + const closeIndex = type.indexOf(")", colonIndex + 1); + if (closeIndex === -1) return type; + + return `${type.slice(0, closeIndex + 1)} => ...${type.slice( + closeIndex + 1 + )}`; + } + + return type; +} + export function addMissingParentheses(type: string): string { const openStack: (typeof openParentheses)[number][] = []; const missingClosingChars: string[] = []; @@ -49,10 +80,8 @@ export function addMissingParentheses(type: string): string { validType += "..."; } - validType = (validType + "\n..." + missingClosingChars.join("")).replace( - // Change (param: ...) to (param) => __RETURN_TYPE__ if needed - /(\([a-zA-Z0-9]*:[^)]*\))/, - (p1) => `${p1} => ...` + validType = addMissingFunctionReturnType( + validType + "\n..." + missingClosingChars.join("") ); return validType; diff --git a/packages/formatter/test/formatter.vitest.ts b/packages/formatter/test/formatter.vitest.ts index 2e73a84..f70dbc2 100644 --- a/packages/formatter/test/formatter.vitest.ts +++ b/packages/formatter/test/formatter.vitest.ts @@ -27,6 +27,16 @@ describe("formatter", (context) => { ); }); + it("adds missing function return types", () => { + expect(addMissingParentheses("(ref: any)")).toBe("(ref: any) => ...\n..."); + }); + + it("handles adversarial missing-parentheses parameter patterns without catastrophic backtracking", () => { + const message = "(:".repeat(40_000); + + expect(addMissingParentheses(message)).toContain(" => ..."); + }, 2_000); + it("handles adversarial formatter patterns without catastrophic backtracking", async () => { const repeat = 5_000; const messages = [ From fc92d1f42b22d969703091192be46cf14e162da9 Mon Sep 17 00:00:00 2001 From: Sean Doherty Date: Sun, 17 May 2026 05:58:31 -0500 Subject: [PATCH 3/3] fix quoted formatter ReDoS regressions --- .../formatter/src/errorMessagePrettifier.ts | 40 +++++++++++++------ .../__snapshots__/formatter.vitest.ts.snap | 4 +- packages/formatter/test/formatter.vitest.ts | 30 ++++++++++++++ 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/packages/formatter/src/errorMessagePrettifier.ts b/packages/formatter/src/errorMessagePrettifier.ts index ce1bdb4..7a28b3b 100644 --- a/packages/formatter/src/errorMessagePrettifier.ts +++ b/packages/formatter/src/errorMessagePrettifier.ts @@ -69,15 +69,23 @@ async function replaceQuotedStringLiteralTypes( if (startIndex === -1) break; const contentStart = startIndex + 3; - let endIndex = contentStart; - while (true) { - endIndex = output.indexOf(`"'`, endIndex); - if (endIndex === -1) break; - if (output[endIndex - 1] !== "\\") break; - endIndex += 1; + let endIndex = -1; + for (let i = contentStart; i < output.length - 1; i++) { + if (output[i] === "\n" || output[i] === "\r") break; + if ( + output[i] === `"` && + output[i + 1] === "'" && + output[i - 1] !== "\\" + ) { + endIndex = i; + break; + } } - if (endIndex === -1) break; + if (endIndex === -1) { + searchIndex = contentStart; + continue; + } result += output.slice(lastIndex, startIndex); result += await formatTypeBlock( @@ -86,8 +94,7 @@ async function replaceQuotedStringLiteralTypes( codeBlock ); - const matchEnd = endIndex + 2; - lastIndex = matchEnd < output.length ? matchEnd + 1 : matchEnd; + lastIndex = endIndex + 2; searchIndex = lastIndex; } @@ -174,9 +181,18 @@ async function getRules(codeBlock: CodeBlockFn): Promise { }, { pattern: - /(module|file|file name|imported via) ['"“]([^'"“”\r\n]*)['"“](?=[\s(.|,]|$)/gi, - replacer: async (p1: string, p2: string) => - formatTypeBlock(p1, `"${p2}"`, codeBlock), + /(module|file|file name|imported via) (?:"([^"\r\n]*)"|'([^'\r\n]*)'|“([^“”\r\n]*)[“”])(?=[\s(.|,]|$)/gi, + replacer: async ( + p1: string, + doubleQuoted: string | undefined, + singleQuoted: string | undefined, + curlyQuoted: string | undefined + ) => + formatTypeBlock( + p1, + `"${doubleQuoted ?? singleQuoted ?? curlyQuoted ?? ""}"`, + codeBlock + ), }, { pattern: diff --git a/packages/formatter/test/__snapshots__/formatter.vitest.ts.snap b/packages/formatter/test/__snapshots__/formatter.vitest.ts.snap index 558a9d4..722b58f 100644 --- a/packages/formatter/test/__snapshots__/formatter.vitest.ts.snap +++ b/packages/formatter/test/__snapshots__/formatter.vitest.ts.snap @@ -112,7 +112,7 @@ exports[`formatter > prettifies mock error message: errorWithSimpleIndentations exports[`formatter > prettifies mock error message: errorWithSpecialCharsInObjectKeys 1`] = `"Type \`string\` is not assignable to type \`{ "abc*bc": string }\`."`; -exports[`formatter > prettifies mock error message: errorWithStringChars 1`] = `"Type \`"' 'Oh no"\`is not assignable to type \`"' 'Oh n"o"\` "'."`; +exports[`formatter > prettifies mock error message: errorWithStringChars 1`] = `"Type \`"' 'Oh no"\` is not assignable to type \`"' 'Oh n"o"\` "'."`; exports[`formatter > prettifies mock error message: errorWithTruncatedType2 1`] = ` "Type: @@ -181,7 +181,7 @@ exports[`formatter > prettifies mock error message: ts1378Error 1`] = `"Top-leve exports[`formatter > prettifies mock error message: ts2304Error 1`] = `"Cannot find name \`varname\` ."`; -exports[`formatter > prettifies mock error message: ts2305Error 1`] = `"Module \`"@pretty-ts-errors/formatter"\`has no exported member \`values\` ."`; +exports[`formatter > prettifies mock error message: ts2305Error 1`] = `"Module \`"@pretty-ts-errors/formatter"\` has no exported member \`values\` ."`; exports[`formatter > prettifies mock error message: ts2307Error 1`] = `"Cannot find module \`"events"\` or its corresponding type declarations."`; diff --git a/packages/formatter/test/formatter.vitest.ts b/packages/formatter/test/formatter.vitest.ts index f70dbc2..2acfe43 100644 --- a/packages/formatter/test/formatter.vitest.ts +++ b/packages/formatter/test/formatter.vitest.ts @@ -41,10 +41,16 @@ describe("formatter", (context) => { const repeat = 5_000; const messages = [ "\\" + "\t'\"\t".repeat(repeat) + "\n", + ";'declare module ”".repeat(repeat) + ";”\n'declare module '“;'", "is missing the following properties from type's".repeat(repeat) + "\n", + "TYPES “".repeat(repeat) + "'\nTYPES “” AND ''", "TYPE ANNOTATION MUST BE “".repeat(repeat) + "'", "OVERLOAD 0 OF 0, “".repeat(repeat) + "\nOVERLOAD 0 OF 0, '', ", + " " + 'FILE "P'.repeat(repeat) + "\n", "E" + "MTYPE 'R".repeat(repeat) + "\n", + " FILE“".repeat(repeat) + " FILE", + "'0" + "0".repeat(repeat) + "\n“0”", + "RETURN “".repeat(repeat) + "\nRETURN '”", "'" + "'0'0\x00".repeat(repeat) + "\n", ]; @@ -53,6 +59,30 @@ describe("formatter", (context) => { } }, 2_000); + it("does not prettify quoted string literals across line breaks", async () => { + const message = "Type '\"first\nsecond\"' remains unjoined."; + + expect(await prettifyErrorMessage(message)).toContain( + "'\"first\nsecond\"' remains" + ); + }); + + it("preserves text after quoted string literal replacements", async () => { + await expect( + prettifyErrorMessage(`Type '"' 'Oh no"' is not assignable.`) + ).resolves.toContain("`\"' 'Oh no\"` is not assignable"); + }); + + it("formats double-quoted module names containing apostrophes", async () => { + await expect( + prettifyErrorMessage( + `Cannot find module "C:/Users/O'Connor/project/file.ts" or its corresponding type declarations.` + ) + ).resolves.toBe( + `Cannot find module \`"C:/Users/O'Connor/project/file.ts"\` or its corresponding type declarations.` + ); + }); + it("formats Special characters in object keys", async () => { expect(await prettifyErrorMessage(errorWithSpecialCharsInObjectKeys)).toBe( 'Type `string` is not assignable to type `{ "abc*bc": string }`.'