From f77ddf489050b339faa978bb28da954bafd289ae Mon Sep 17 00:00:00 2001 From: GCHQDeveloper581 <63102987+GCHQDeveloper581@users.noreply.github.com> Date: Fri, 24 Jul 2026 07:42:55 +0100 Subject: [PATCH] Fix pretty recipe parser ReDoS (#2687) Co-authored-by: zainnadeem(RedOpsCell) (main author) --- src/core/Utils.mjs | 48 ++++++++++++++++++++++++ tests/node/tests/Utils.mjs | 77 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/src/core/Utils.mjs b/src/core/Utils.mjs index eae86374..cb641e3d 100755 --- a/src/core/Utils.mjs +++ b/src/core/Utils.mjs @@ -1015,6 +1015,7 @@ class Utils { // Parse bespoke recipe format recipe = recipe.replace(/\n/g, ""); + Utils._validatePrettyRecipe(recipe); let m, args; const recipeRegex = /([^(]+)\(((?:'[^'\\]*(?:\\.[^'\\]*)*'|[^)/'])*)(\/[^)]+)?\)/g, recipeConfig = []; @@ -1040,6 +1041,53 @@ class Utils { } + /** + * Performs a linear structural validation pass over pretty recipe syntax. + * + * @param {string} recipe + * @throws {Error} if the recipe is structurally invalid + */ + static _validatePrettyRecipe(recipe) { + let i = 0; + + while (i < recipe.length) { + const openParen = recipe.indexOf("(", i); + if (openParen === -1 || openParen === i) { + throw new Error("Invalid recipe"); + } + + i = openParen + 1; + let inString = false, + escaped = false, + foundCloseParen = false; + + for (; i < recipe.length; i++) { + const c = recipe[i]; + + if (inString) { + if (escaped) { + escaped = false; + } else if (c === "\\") { + escaped = true; + } else if (c === "'") { + inString = false; + } + } else if (c === "'") { + inString = true; + } else if (c === ")") { + foundCloseParen = true; + i++; + break; + } + } + + if (!foundCloseParen || inString || escaped) { + throw new Error("Invalid recipe"); + } + } + } + + /** * Formats a list of files or directories. * diff --git a/tests/node/tests/Utils.mjs b/tests/node/tests/Utils.mjs index 5ee7c936..ff4ffd96 100644 --- a/tests/node/tests/Utils.mjs +++ b/tests/node/tests/Utils.mjs @@ -26,4 +26,81 @@ TestRegister.addApiTests([ "\x7e...", ); }), + + it("Utils: should parse normal pretty recipes", () => { + assert.deepStrictEqual( + Utils.parseRecipeConfig("From_Base64('A-Za-z0-9+/=',true)To_Hex('Space')"), + [ + { + op: "From Base64", + args: ["A-Za-z0-9+/=", true], + }, + { + op: "To Hex", + args: ["Space"], + }, + ], + ); + }), + + it("Utils: should parse pretty recipe options", () => { + assert.deepStrictEqual( + Utils.parseRecipeConfig("A(/disabled/breakpoint)"), + [ + { + op: "A", + args: [], + disabled: true, + breakpoint: true, + }, + ], + ); + }), + + it("Utils: should parse escaped quotes and backslashes in pretty recipes", () => { + assert.deepStrictEqual( + Utils.parseRecipeConfig("A('\\'\\\\')"), + [ + { + op: "A", + args: ["'\\"], + }, + ], + ); + }), + + it("Utils: should parse large valid quoted pretty recipe arguments", () => { + const value = "x".repeat(10000); + + assert.deepStrictEqual( + Utils.parseRecipeConfig(`A('${value}')`), + [ + { + op: "A", + args: [value], + }, + ], + ); + }), + + it("Utils: should reject malformed pretty recipes with unmatched quotes", () => { + assert.throws( + () => Utils.parseRecipeConfig("A(" + "'".repeat(10000)), + /Invalid recipe/, + ); + }), + + it("Utils: should reject malformed pretty recipes with malformed parentheses", () => { + assert.throws( + () => Utils.parseRecipeConfig("A("), + /Invalid recipe/, + ); + }), + + it("Utils: should reject malformed pretty recipes with malformed escapes", () => { + assert.throws( + () => Utils.parseRecipeConfig("A('" + "\\".repeat(10000)), + /Invalid recipe/, + ); + }), ]);