Fix pretty recipe parser ReDoS (#2687)

Co-authored-by: zainnadeem(RedOpsCell) <zainnadeemzainnadeem80@gmail.com> (main author)
This commit is contained in:
GCHQDeveloper581 2026-07-24 07:42:55 +01:00 committed by GitHub
parent 1343a107cb
commit f77ddf4890
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 125 additions and 0 deletions

View File

@ -1015,6 +1015,7 @@ class Utils {
// Parse bespoke recipe format // Parse bespoke recipe format
recipe = recipe.replace(/\n/g, ""); recipe = recipe.replace(/\n/g, "");
Utils._validatePrettyRecipe(recipe);
let m, args; let m, args;
const recipeRegex = /([^(]+)\(((?:'[^'\\]*(?:\\.[^'\\]*)*'|[^)/'])*)(\/[^)]+)?\)/g, const recipeRegex = /([^(]+)\(((?:'[^'\\]*(?:\\.[^'\\]*)*'|[^)/'])*)(\/[^)]+)?\)/g,
recipeConfig = []; 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. * Formats a list of files or directories.
* *

View File

@ -26,4 +26,81 @@ TestRegister.addApiTests([
"\x7e...", "\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/,
);
}),
]); ]);