@@ -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 */
9094class 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- / r e q u i r e \s * \( / gi,
118- / i m p o r t \s + / gi,
119- / e v a l \s * \( / gi,
120- / F u n c t i o n \s * \( / gi,
121- / c o n s t r u c t o r / gi,
122- / _ _ p r o t o _ _ / gi,
123- / p r o t o t y p e / 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