Skip to content

Commit 18dbca5

Browse files
Copilothotlong
andcommitted
Fix CodeQL security alert: Replace unsafe Function() with safe expression parser
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 5531c90 commit 18dbca5

1 file changed

Lines changed: 131 additions & 44 deletions

File tree

packages/core/src/validation/validators/object-validation-engine.ts

Lines changed: 131 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -78,74 +78,161 @@ export interface ValidationExpressionEvaluator {
7878
}
7979

8080
/**
81-
* Simple expression evaluator (basic implementation)
82-
* In production, this should use a proper expression engine
81+
* Safe expression evaluator using a simple parser (no dynamic code execution)
8382
*
84-
* SECURITY NOTE: This implementation uses a sandboxed approach with limited
85-
* expression capabilities. For production use, consider:
83+
* SECURITY: This implementation parses expressions into an AST and evaluates them
84+
* without using eval() or new Function(). It supports:
85+
* - Comparison operators: ==, !=, >, <, >=, <=
86+
* - Logical operators: &&, ||, !
87+
* - Property access: record.field, record['field']
88+
* - Literals: true, false, null, numbers, strings
89+
*
90+
* For more complex expressions, integrate a dedicated library like:
8691
* - JSONLogic (jsonlogic.com)
87-
* - expr-eval with allowlist
88-
* - Custom AST-based evaluator
92+
* - filtrex
8993
*/
9094
class SimpleExpressionEvaluator implements ValidationExpressionEvaluator {
9195
evaluate(expression: string, context: Record<string, any>): any {
9296
try {
93-
// Sanitize expression: only allow basic comparisons and logical operators
94-
// This is a basic safeguard - proper expression parsing should be used in production
95-
const sanitizedExpression = this.sanitizeExpression(expression);
96-
97-
// Create a safe evaluation context with read-only access
98-
const safeContext = this.createSafeContext(context);
99-
const contextKeys = Object.keys(safeContext);
100-
const contextValues = Object.values(safeContext);
101-
102-
// Use Function constructor with controlled input
103-
const func = new Function(...contextKeys, `'use strict'; return (${sanitizedExpression});`);
104-
return func(...contextValues);
97+
return this.evaluateSafeExpression(expression.trim(), context);
10598
} catch (error) {
10699
console.error('Expression evaluation error:', error);
107100
return false;
108101
}
109102
}
110103

111104
/**
112-
* Sanitize expression to prevent code injection
105+
* Safely evaluate an expression without using dynamic code execution
113106
*/
114-
private sanitizeExpression(expression: string): string {
115-
// Remove potentially dangerous patterns
116-
const dangerous = [
117-
/require\s*\(/gi,
118-
/import\s+/gi,
119-
/eval\s*\(/gi,
120-
/Function\s*\(/gi,
121-
/constructor/gi,
122-
/__proto__/gi,
123-
/prototype/gi,
124-
];
125-
126-
for (const pattern of dangerous) {
127-
if (pattern.test(expression)) {
128-
throw new Error('Invalid expression: contains forbidden pattern');
107+
private evaluateSafeExpression(expr: string, context: Record<string, any>): any {
108+
// Handle boolean literals
109+
if (expr === 'true') return true;
110+
if (expr === 'false') return false;
111+
if (expr === 'null') return null;
112+
113+
// Handle string literals
114+
if ((expr.startsWith('"') && expr.endsWith('"')) ||
115+
(expr.startsWith("'") && expr.endsWith("'"))) {
116+
return expr.slice(1, -1);
117+
}
118+
119+
// Handle numeric literals
120+
if (/^-?\d+(\.\d+)?$/.test(expr)) {
121+
return parseFloat(expr);
122+
}
123+
124+
// Handle logical NOT
125+
if (expr.startsWith('!')) {
126+
return !this.evaluateSafeExpression(expr.slice(1).trim(), context);
127+
}
128+
129+
// Handle logical AND
130+
if (expr.includes('&&')) {
131+
const parts = this.splitOnOperator(expr, '&&');
132+
return parts.every(part => this.evaluateSafeExpression(part, context));
133+
}
134+
135+
// Handle logical OR
136+
if (expr.includes('||')) {
137+
const parts = this.splitOnOperator(expr, '||');
138+
return parts.some(part => this.evaluateSafeExpression(part, context));
139+
}
140+
141+
// Handle comparison operators
142+
const comparisonMatch = expr.match(/^(.+?)\s*(===|!==|==|!=|>=|<=|>|<)\s*(.+)$/);
143+
if (comparisonMatch) {
144+
const [, left, op, right] = comparisonMatch;
145+
const leftVal = this.evaluateSafeExpression(left.trim(), context);
146+
const rightVal = this.evaluateSafeExpression(right.trim(), context);
147+
148+
switch (op) {
149+
case '===':
150+
case '==': return leftVal == rightVal;
151+
case '!==':
152+
case '!=': return leftVal != rightVal;
153+
case '>': return leftVal > rightVal;
154+
case '<': return leftVal < rightVal;
155+
case '>=': return leftVal >= rightVal;
156+
case '<=': return leftVal <= rightVal;
157+
default: return false;
129158
}
130159
}
160+
161+
// Handle property access (e.g., record.field or context.field)
162+
return this.getValueFromContext(expr, context);
163+
}
131164

132-
return expression;
165+
/**
166+
* Split expression on operator, respecting parentheses and quotes
167+
*/
168+
private splitOnOperator(expr: string, operator: string): string[] {
169+
const parts: string[] = [];
170+
let current = '';
171+
let depth = 0;
172+
let inString = false;
173+
let stringChar = '';
174+
175+
for (let i = 0; i < expr.length; i++) {
176+
const char = expr[i];
177+
const nextChar = expr[i + 1];
178+
179+
if ((char === '"' || char === "'") && !inString) {
180+
inString = true;
181+
stringChar = char;
182+
} else if (char === stringChar && inString) {
183+
inString = false;
184+
}
185+
186+
if (!inString) {
187+
if (char === '(') depth++;
188+
if (char === ')') depth--;
189+
190+
if (depth === 0 && char === operator[0] && nextChar === operator[1]) {
191+
parts.push(current.trim());
192+
current = '';
193+
i++; // Skip next character
194+
continue;
195+
}
196+
}
197+
198+
current += char;
199+
}
200+
201+
if (current) {
202+
parts.push(current.trim());
203+
}
204+
205+
return parts;
133206
}
134207

135208
/**
136-
* Create a safe read-only context
209+
* Get value from context by path (e.g., "record.age" or "age")
137210
*/
138-
private createSafeContext(context: Record<string, any>): Record<string, any> {
139-
const safe: Record<string, any> = {};
140-
for (const [key, value] of Object.entries(context)) {
141-
// Deep clone primitive values and objects to prevent mutation
142-
if (typeof value === 'object' && value !== null) {
143-
safe[key] = JSON.parse(JSON.stringify(value));
211+
private getValueFromContext(path: string, context: Record<string, any>): any {
212+
// Handle bracket notation: record['field']
213+
const bracketMatch = path.match(/^(\w+)\['([^']+)'\]$/);
214+
if (bracketMatch) {
215+
const [, obj, field] = bracketMatch;
216+
return context[obj]?.[field];
217+
}
218+
219+
// Handle dot notation: record.field or just field
220+
const parts = path.split('.');
221+
let value: any = context;
222+
223+
for (const part of parts) {
224+
if (value && typeof value === 'object' && part in value) {
225+
value = value[part];
144226
} else {
145-
safe[key] = value;
227+
// Try direct context access for simple identifiers
228+
if (parts.length === 1 && part in context) {
229+
return context[part];
230+
}
231+
return undefined;
146232
}
147233
}
148-
return safe;
234+
235+
return value;
149236
}
150237
}
151238

0 commit comments

Comments
 (0)