Skip to content

Commit b6dcf89

Browse files
authored
improve condition sanitization (#83)
* improve condition sanitization * fix arrays
1 parent 6e2320f commit b6dcf89

File tree

2 files changed

+396
-2
lines changed

2 files changed

+396
-2
lines changed

lib/condition-validator.ts

Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
/**
2+
* Condition Expression Validator
3+
*
4+
* Validates and sanitizes condition expressions before evaluation.
5+
* This prevents arbitrary code execution while allowing useful comparisons.
6+
*
7+
* Allowed syntax:
8+
* - Template variables: {{@nodeId:Label.field}} (replaced with safe __v0, __v1, etc.)
9+
* - Comparison operators: ===, !==, ==, !=, >, <, >=, <=
10+
* - Logical operators: &&, ||, !
11+
* - Grouping: ( )
12+
* - Literals: strings ('...', "..."), numbers, true, false, null, undefined
13+
* - Property access on variables: __v0.property, __v0[0], __v0["key"]
14+
* - Array methods: .includes(), .length
15+
* - String methods: .startsWith(), .endsWith(), .includes()
16+
*
17+
* NOT allowed:
18+
* - Function calls (except allowed methods)
19+
* - Assignment operators (=, +=, -=, etc.)
20+
* - Code execution constructs (eval, Function, import, require)
21+
* - Property assignment
22+
* - Array/object literals ([1,2,3], {key: value})
23+
* - Comments
24+
*/
25+
26+
// Dangerous patterns that should never appear in conditions
27+
const DANGEROUS_PATTERNS = [
28+
// Assignment operators
29+
/(?<![=!<>])=(?!=)/g, // = but not ==, ===, !=, !==, <=, >=
30+
/\+=|-=|\*=|\/=|%=|\^=|\|=|&=/g,
31+
// Code execution
32+
/\beval\s*\(/gi,
33+
/\bFunction\s*\(/gi,
34+
/\bimport\s*\(/gi,
35+
/\brequire\s*\(/gi,
36+
/\bnew\s+\w/gi,
37+
// Dangerous globals
38+
/\bprocess\b/gi,
39+
/\bglobal\b/gi,
40+
/\bwindow\b/gi,
41+
/\bdocument\b/gi,
42+
/\bconstructor\b/gi,
43+
/\b__proto__\b/gi,
44+
/\bprototype\b/gi,
45+
// Control flow that could be exploited
46+
/\bwhile\s*\(/gi,
47+
/\bfor\s*\(/gi,
48+
/\bdo\s*\{/gi,
49+
/\bswitch\s*\(/gi,
50+
/\btry\s*\{/gi,
51+
/\bcatch\s*\(/gi,
52+
/\bfinally\s*\{/gi,
53+
/\bthrow\s+/gi,
54+
/\breturn\s+/gi,
55+
// Template literals with expressions (could execute code)
56+
/`[^`]*\$\{/g,
57+
// Object literals (but NOT bracket property access)
58+
/\{\s*\w+\s*:/g,
59+
// Increment/decrement
60+
/\+\+|--/g,
61+
// Bitwise operators (rarely needed, often used in exploits)
62+
/<<|>>|>>>/g,
63+
// Comma operator (can chain expressions)
64+
/,(?![^(]*\))/g, // Comma not inside function call parentheses
65+
// Semicolons (statement separator)
66+
/;/g,
67+
];
68+
69+
// Allowed method names that can be called
70+
const ALLOWED_METHODS = new Set([
71+
"includes",
72+
"startsWith",
73+
"endsWith",
74+
"toString",
75+
"toLowerCase",
76+
"toUpperCase",
77+
"trim",
78+
"length", // Actually a property, but accessed like .length
79+
]);
80+
81+
// Pattern to match method calls
82+
const METHOD_CALL_PATTERN = /\.(\w+)\s*\(/g;
83+
84+
// Pattern to match bracket expressions: captures what's before and inside the brackets
85+
const BRACKET_EXPRESSION_PATTERN = /(\w+)\s*\[([^\]]+)\]/g;
86+
87+
// Pattern for valid variable property access: __v0[0], __v0["key"], __v0['key']
88+
const VALID_BRACKET_ACCESS_PATTERN = /^__v\d+$/;
89+
const VALID_BRACKET_CONTENT_PATTERN = /^(\d+|'[^']*'|"[^"]*")$/;
90+
91+
// Top-level regex patterns for token validation
92+
const WHITESPACE_SPLIT_PATTERN = /\s+/;
93+
const VARIABLE_TOKEN_PATTERN = /^__v\d+/;
94+
const STRING_TOKEN_PATTERN = /^['"]/;
95+
const NUMBER_TOKEN_PATTERN = /^\d/;
96+
const LITERAL_TOKEN_PATTERN = /^(true|false|null|undefined)$/;
97+
const OPERATOR_TOKEN_PATTERN = /^(===|!==|==|!=|>=|<=|>|<|&&|\|\||!|\(|\))$/;
98+
const IDENTIFIER_TOKEN_PATTERN = /^[a-zA-Z_]\w*$/;
99+
100+
export type ValidationResult =
101+
| { valid: true }
102+
| { valid: false; error: string };
103+
104+
/**
105+
* Check for dangerous patterns in the expression
106+
*/
107+
function checkDangerousPatterns(expression: string): ValidationResult {
108+
for (const pattern of DANGEROUS_PATTERNS) {
109+
// Reset regex state
110+
pattern.lastIndex = 0;
111+
if (pattern.test(expression)) {
112+
pattern.lastIndex = 0;
113+
const match = expression.match(pattern);
114+
return {
115+
valid: false,
116+
error: `Condition contains disallowed syntax: "${match?.[0] || "unknown"}"`,
117+
};
118+
}
119+
}
120+
return { valid: true };
121+
}
122+
123+
/**
124+
* Check bracket expressions to distinguish between:
125+
* - Allowed: Variable property access like __v0[0], __v0["key"], __v0['key']
126+
* - Blocked: Array literals like [1,2,3], or dangerous expressions like __v0[eval('x')]
127+
*/
128+
function checkBracketExpressions(expression: string): ValidationResult {
129+
BRACKET_EXPRESSION_PATTERN.lastIndex = 0;
130+
131+
// Use exec loop for compatibility
132+
let match: RegExpExecArray | null = null;
133+
while (true) {
134+
match = BRACKET_EXPRESSION_PATTERN.exec(expression);
135+
if (match === null) {
136+
break;
137+
}
138+
139+
const beforeBracket = match[1];
140+
const insideBracket = match[2].trim();
141+
142+
// Check if the part before the bracket is a valid variable (__v0, __v1, etc.)
143+
if (!VALID_BRACKET_ACCESS_PATTERN.test(beforeBracket)) {
144+
return {
145+
valid: false,
146+
error: `Bracket notation is only allowed on workflow variables. Found: "${beforeBracket}[...]"`,
147+
};
148+
}
149+
150+
// Check if the content inside brackets is safe (number or string literal)
151+
if (!VALID_BRACKET_CONTENT_PATTERN.test(insideBracket)) {
152+
return {
153+
valid: false,
154+
error: `Invalid bracket content: "[${insideBracket}]". Only numeric indices or string literals are allowed.`,
155+
};
156+
}
157+
}
158+
159+
// Check for standalone array literals (brackets not preceded by a variable)
160+
// This catches cases like "[1, 2, 3]" at the start of expression or after operators
161+
const standaloneArrayPattern = /(?:^|[=!<>&|(\s])\s*\[/g;
162+
standaloneArrayPattern.lastIndex = 0;
163+
if (standaloneArrayPattern.test(expression)) {
164+
return {
165+
valid: false,
166+
error:
167+
"Array literals are not allowed in conditions. Use workflow variables instead.",
168+
};
169+
}
170+
171+
return { valid: true };
172+
}
173+
174+
/**
175+
* Check that all method calls use allowed methods
176+
*/
177+
function checkMethodCalls(expression: string): ValidationResult {
178+
METHOD_CALL_PATTERN.lastIndex = 0;
179+
180+
// Use exec loop for compatibility
181+
let match: RegExpExecArray | null = null;
182+
while (true) {
183+
match = METHOD_CALL_PATTERN.exec(expression);
184+
if (match === null) {
185+
break;
186+
}
187+
188+
const methodName = match[1];
189+
if (!ALLOWED_METHODS.has(methodName)) {
190+
return {
191+
valid: false,
192+
error: `Method "${methodName}" is not allowed in conditions. Allowed methods: ${Array.from(ALLOWED_METHODS).join(", ")}`,
193+
};
194+
}
195+
}
196+
197+
return { valid: true };
198+
}
199+
200+
/**
201+
* Check that parentheses are balanced
202+
*/
203+
function checkParentheses(expression: string): ValidationResult {
204+
let parenDepth = 0;
205+
206+
for (const char of expression) {
207+
if (char === "(") {
208+
parenDepth += 1;
209+
}
210+
if (char === ")") {
211+
parenDepth -= 1;
212+
}
213+
if (parenDepth < 0) {
214+
return { valid: false, error: "Unbalanced parentheses in condition" };
215+
}
216+
}
217+
218+
if (parenDepth !== 0) {
219+
return { valid: false, error: "Unbalanced parentheses in condition" };
220+
}
221+
222+
return { valid: true };
223+
}
224+
225+
/**
226+
* Check if a token is valid
227+
*/
228+
function isValidToken(token: string): boolean {
229+
// Skip known valid patterns
230+
if (VARIABLE_TOKEN_PATTERN.test(token)) {
231+
return true;
232+
}
233+
if (STRING_TOKEN_PATTERN.test(token)) {
234+
return true;
235+
}
236+
if (NUMBER_TOKEN_PATTERN.test(token)) {
237+
return true;
238+
}
239+
if (LITERAL_TOKEN_PATTERN.test(token)) {
240+
return true;
241+
}
242+
if (OPERATOR_TOKEN_PATTERN.test(token)) {
243+
return true;
244+
}
245+
return false;
246+
}
247+
248+
/**
249+
* Check for unauthorized identifiers in the expression
250+
*/
251+
function checkUnauthorizedIdentifiers(expression: string): ValidationResult {
252+
const tokens = expression.split(WHITESPACE_SPLIT_PATTERN).filter(Boolean);
253+
254+
for (const token of tokens) {
255+
if (isValidToken(token)) {
256+
continue;
257+
}
258+
259+
// Check if it looks like an unauthorized identifier
260+
if (IDENTIFIER_TOKEN_PATTERN.test(token) && !token.startsWith("__v")) {
261+
return {
262+
valid: false,
263+
error: `Unknown identifier "${token}" in condition. Use template variables like {{@nodeId:Label.field}} to reference workflow data.`,
264+
};
265+
}
266+
}
267+
268+
return { valid: true };
269+
}
270+
271+
/**
272+
* Validate a condition expression after template variables have been replaced
273+
*
274+
* @param expression - The expression with template vars replaced (e.g., "__v0 === 'test'")
275+
* @returns ValidationResult indicating if the expression is safe to evaluate
276+
*/
277+
export function validateConditionExpression(
278+
expression: string
279+
): ValidationResult {
280+
// Empty expressions are invalid
281+
if (!expression || expression.trim() === "") {
282+
return { valid: false, error: "Condition expression cannot be empty" };
283+
}
284+
285+
// Check for dangerous patterns
286+
const dangerousCheck = checkDangerousPatterns(expression);
287+
if (!dangerousCheck.valid) {
288+
return dangerousCheck;
289+
}
290+
291+
// Check bracket expressions (array access vs array literals)
292+
const bracketCheck = checkBracketExpressions(expression);
293+
if (!bracketCheck.valid) {
294+
return bracketCheck;
295+
}
296+
297+
// Check method calls are allowed
298+
const methodCheck = checkMethodCalls(expression);
299+
if (!methodCheck.valid) {
300+
return methodCheck;
301+
}
302+
303+
// Validate balanced parentheses
304+
const parenCheck = checkParentheses(expression);
305+
if (!parenCheck.valid) {
306+
return parenCheck;
307+
}
308+
309+
// Check for unauthorized identifiers
310+
const identifierCheck = checkUnauthorizedIdentifiers(expression);
311+
if (!identifierCheck.valid) {
312+
return identifierCheck;
313+
}
314+
315+
return { valid: true };
316+
}
317+
318+
/**
319+
* Check if a raw expression (before template replacement) looks safe
320+
* This is a quick pre-check before the more thorough validation
321+
*/
322+
export function preValidateConditionExpression(
323+
expression: string
324+
): ValidationResult {
325+
if (!expression || typeof expression !== "string") {
326+
return { valid: false, error: "Condition must be a non-empty string" };
327+
}
328+
329+
// Check for obviously dangerous patterns before any processing
330+
const dangerousKeywords = [
331+
"eval",
332+
"Function",
333+
"import",
334+
"require",
335+
"process",
336+
"global",
337+
"window",
338+
"document",
339+
"__proto__",
340+
"constructor",
341+
"prototype",
342+
];
343+
344+
const lowerExpression = expression.toLowerCase();
345+
for (const keyword of dangerousKeywords) {
346+
if (lowerExpression.includes(keyword.toLowerCase())) {
347+
return {
348+
valid: false,
349+
error: `Condition contains disallowed keyword: "${keyword}"`,
350+
};
351+
}
352+
}
353+
354+
return { valid: true };
355+
}
356+
357+
/**
358+
* Sanitize an expression by escaping potentially dangerous characters
359+
* This is used as an additional safety measure
360+
*/
361+
export function sanitizeForDisplay(expression: string): string {
362+
return expression
363+
.replace(/</g, "&lt;")
364+
.replace(/>/g, "&gt;")
365+
.replace(/"/g, "&quot;")
366+
.replace(/'/g, "&#39;");
367+
}

0 commit comments

Comments
 (0)