Files
twenty/packages/twenty-shared/src/utils/variable-resolver.ts
T
Abdullah. 1f1a1ea138 fix(workflows): align variable regex with validation and prevent ReDoS (#16607)
**What this fixes:**
- Addresses a CodeQL security finding: the regex used to find variables
in workflow strings could be slow on malicious inputs (ReDoS).
- Two alerts: [Code Scanning
181](https://github.com/twentyhq/twenty/security/code-scanning/181) and
[Code Scanning
182](https://github.com/twentyhq/twenty/security/code-scanning/182)

**Context:**
- Our workflow system lets users insert variables like `{{user.name}}`
or `{{trigger.properties.after.name}}` into strings and JSON (HTTP
request bodies, record field values, etc.).
- The `variable-resolver.ts` module scans these strings and replaces
variables with actual values.
- Our validation (`isValidVariable`) already enforces that variables
contain no `{` or `}` inside them (only simple property paths like
`user.name`).

**The change:**
- Updated the regex from `/\{\{(.*?)\}\}/g` to `/\{\{([^{}]+)\}\}/g` to
match our validation pattern.
- This removes the ReDoS risk and aligns the resolver with the
validation contract.

**Why this is safe:**
- All supported workflow usage (simple variable paths) continues to
work.
- Both `match` and `replace` behave the same for valid variables.
- Only unsupported patterns with nested braces (e.g., `{{foo {bar}}}`)
would stop matching, which isn't part of our supported syntax anyway.
2025-12-17 17:13:55 +01:00

107 lines
2.5 KiB
TypeScript

import Handlebars from 'handlebars';
const isNil = (value: any): value is null | undefined => {
return value === null || value === undefined;
};
const isString = (value: any): value is string => {
return typeof value === 'string';
};
const VARIABLE_PATTERN = RegExp('\\{\\{([^{}]+)\\}\\}', 'g');
export const resolveInput = (
unresolvedInput: unknown,
context: Record<string, unknown>,
): unknown => {
if (isNil(unresolvedInput)) {
return unresolvedInput;
}
if (isString(unresolvedInput)) {
return resolveString(unresolvedInput, context);
}
if (Array.isArray(unresolvedInput)) {
return resolveArray(unresolvedInput, context);
}
if (typeof unresolvedInput === 'object' && unresolvedInput !== null) {
return resolveObject(unresolvedInput, context);
}
return unresolvedInput;
};
const resolveArray = (
input: unknown[],
context: Record<string, unknown>,
): unknown[] => {
const resolvedArray = input;
for (let i = 0; i < input.length; ++i) {
resolvedArray[i] = resolveInput(input[i], context);
}
return resolvedArray;
};
const resolveObject = (
input: object,
context: Record<string, unknown>,
): object => {
return Object.entries(input).reduce<Record<string, unknown>>(
(resolvedObject, [key, value]) => {
const resolvedKey = resolveInput(key, context);
resolvedObject[
typeof resolvedKey === 'string' ? resolvedKey : String(resolvedKey)
] = resolveInput(value, context);
return resolvedObject;
},
{},
);
};
const resolveString = (
input: string,
context: Record<string, unknown>,
): string => {
const matchedTokens = input.match(VARIABLE_PATTERN);
if (!matchedTokens || matchedTokens.length === 0) {
return input;
}
if (matchedTokens.length === 1 && matchedTokens[0] === input) {
return evalFromContext(input, context);
}
return input.replace(VARIABLE_PATTERN, (matchedToken, _) => {
const processedToken = evalFromContext(matchedToken, context);
return processedToken;
});
};
const evalFromContext = (input: string, context: Record<string, unknown>) => {
try {
Handlebars.registerHelper('json', (input: string) => JSON.stringify(input));
const inputWithHelper = input
.replace('{{', '{{{ json ')
.replace('}}', ' }}}');
const inferredInput = Handlebars.compile(inputWithHelper)(context, {
helpers: {
json: (input: string) => JSON.stringify(input),
},
});
return JSON.parse(inferredInput);
} catch {
return undefined;
}
};