UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

407 lines 15.4 kB
/** * QueryValidator - Validates queries for safety, permissions, and complexity */ import { SAFE_JSON_FUNCTIONS, QUERY_LIMITS, ERROR_CODES } from './constants.js'; export class QueryValidator { permissions; constructor(permissions = {}) { this.permissions = { allowComplexQueries: true, allowJsonProcessing: true, maxResultLimit: QUERY_LIMITS.maxLimit, ...permissions }; } /** * Validate a query intent before building */ validateIntent(intent) { const errors = []; const warnings = []; // Validate entity access if (this.permissions.allowedEntities && !this.permissions.allowedEntities.includes(intent.primaryEntity)) { errors.push({ code: ERROR_CODES.PERMISSION_ERROR, message: `Access denied to entity type: ${intent.primaryEntity}`, field: 'primaryEntity', value: intent.primaryEntity }); } // Validate filters if (intent.filters) { for (const filter of intent.filters) { const filterValidation = this.validateFilter(filter); errors.push(...filterValidation.errors); warnings.push(...filterValidation.warnings); } } // Validate limit if (intent.limit) { if (intent.limit > (this.permissions.maxResultLimit || QUERY_LIMITS.maxLimit)) { errors.push({ code: ERROR_CODES.VALIDATION_ERROR, message: `Result limit ${intent.limit} exceeds maximum allowed ${this.permissions.maxResultLimit}`, field: 'limit', value: intent.limit }); } } // Validate grouping if (intent.groupBy && intent.groupBy.length > QUERY_LIMITS.maxGroupByFields) { errors.push({ code: ERROR_CODES.COMPLEXITY_ERROR, message: `Too many grouping fields: ${intent.groupBy.length} (max: ${QUERY_LIMITS.maxGroupByFields})`, field: 'groupBy', value: intent.groupBy }); } // Validate complexity const complexity = this.calculateIntentComplexity(intent); if (complexity > 100 && !this.permissions.allowComplexQueries) { errors.push({ code: ERROR_CODES.COMPLEXITY_ERROR, message: 'Query is too complex for current permissions', field: 'complexity', value: complexity }); } // Add warnings for performance if (complexity > 50) { warnings.push({ code: 'PERFORMANCE_WARNING', message: 'This query may be slow due to complexity', suggestion: 'Consider adding more specific filters or reducing the scope' }); } return { valid: errors.length === 0, errors, warnings }; } /** * Validate a compiled SQL query */ validateCompiledQuery(query) { const errors = []; const warnings = []; // Check for SQL injection attempts const injectionCheck = this.checkSqlInjection(query.sql); if (!injectionCheck.safe) { errors.push({ code: ERROR_CODES.VALIDATION_ERROR, message: `Potential SQL injection detected: ${injectionCheck.reason}`, field: 'sql', value: injectionCheck.suspicious }); } // Validate parameters const paramCount = (query.sql.match(/\?/g) || []).length; if (paramCount !== query.params.length) { errors.push({ code: ERROR_CODES.VALIDATION_ERROR, message: `Parameter count mismatch: SQL expects ${paramCount}, got ${query.params.length}`, field: 'params' }); } // Check for unsafe functions const unsafeFunctions = this.checkUnsafeFunctions(query.sql); if (unsafeFunctions.length > 0) { errors.push({ code: ERROR_CODES.VALIDATION_ERROR, message: `Unsafe functions detected: ${unsafeFunctions.join(', ')}`, field: 'sql' }); } // Sanitize if needed let sanitizedQuery = query; if (errors.length === 0) { sanitizedQuery = this.sanitizeQuery(query); } return { valid: errors.length === 0, errors, warnings, sanitizedQuery }; } /** * Validate a hybrid query */ validateHybridQuery(query) { // First validate the SQL part const sqlValidation = this.validateCompiledQuery({ sql: query.sql, params: query.params }); if (!sqlValidation.valid) { return sqlValidation; } const errors = []; const warnings = []; // Validate JSONata expression if present if (query.jsonataExpression && !this.permissions.allowJsonProcessing) { errors.push({ code: ERROR_CODES.PERMISSION_ERROR, message: 'JSON processing is not allowed with current permissions', field: 'jsonataExpression' }); } if (query.jsonataExpression) { const jsonataValidation = this.validateJsonataExpression(query.jsonataExpression); errors.push(...jsonataValidation.errors); warnings.push(...jsonataValidation.warnings); } // Validate processing pipeline if (query.processingPipeline) { const pipelineComplexity = query.processingPipeline.length; if (pipelineComplexity > 10) { warnings.push({ code: 'COMPLEXITY_WARNING', message: `Processing pipeline has ${pipelineComplexity} steps`, suggestion: 'Consider simplifying the query' }); } } return { valid: errors.length === 0, errors, warnings, sanitizedQuery: errors.length === 0 ? query : undefined }; } validateFilter(filter) { const errors = []; const warnings = []; // Validate field name if (!filter.field || filter.field.length === 0) { errors.push({ code: ERROR_CODES.VALIDATION_ERROR, message: 'Filter field is required', field: 'filter.field' }); } // Validate operator const validOperators = [ 'eq', 'ne', 'gt', 'lt', 'gte', 'lte', 'in', 'not_in', 'contains', 'not_contains', 'exists', 'not_exists', 'array_contains', 'array_length' ]; if (!validOperators.includes(filter.operator)) { errors.push({ code: ERROR_CODES.VALIDATION_ERROR, message: `Invalid filter operator: ${filter.operator}`, field: 'filter.operator', value: filter.operator }); } // Validate value based on operator if (['exists', 'not_exists'].includes(filter.operator)) { if (filter.value !== undefined) { warnings.push({ code: 'VALIDATION_WARNING', message: `Operator ${filter.operator} does not use value parameter`, suggestion: 'Remove the value parameter' }); } } else if (['in', 'not_in'].includes(filter.operator)) { if (!Array.isArray(filter.value)) { errors.push({ code: ERROR_CODES.VALIDATION_ERROR, message: `Operator ${filter.operator} requires array value`, field: 'filter.value', value: filter.value }); } } // Check for potential injection in JSON paths if (filter.jsonPath) { const pathCheck = this.checkJsonPathInjection(filter.jsonPath); if (!pathCheck.safe) { errors.push({ code: ERROR_CODES.VALIDATION_ERROR, message: `Unsafe JSON path: ${pathCheck.reason}`, field: 'filter.jsonPath', value: filter.jsonPath }); } } return { valid: errors.length === 0, errors, warnings }; } validateJsonataExpression(expression) { const errors = []; const warnings = []; // Check for potentially dangerous patterns const dangerousPatterns = [ /\$eval\(/i, // eval function /\$\$\./, // global context access /function\s*\(/, // function definitions /=>.*=>.*=>/, // deeply nested lambdas ]; for (const pattern of dangerousPatterns) { if (pattern.test(expression)) { errors.push({ code: ERROR_CODES.JSONATA_ERROR, message: `Potentially dangerous JSONata pattern detected: ${pattern}`, field: 'jsonataExpression' }); } } // Check complexity const complexity = this.calculateJsonataComplexity(expression); if (complexity > QUERY_LIMITS.maxJsonataComplexity) { errors.push({ code: ERROR_CODES.COMPLEXITY_ERROR, message: `JSONata expression too complex: ${complexity}`, field: 'jsonataExpression' }); } return { valid: errors.length === 0, errors, warnings }; } checkSqlInjection(sql) { const upperSql = sql.toUpperCase(); // Check for multiple statements if (sql.includes(';') && !sql.endsWith(';')) { return { safe: false, reason: 'Multiple SQL statements detected', suspicious: ';' }; } // Check for dangerous keywords outside of strings const dangerousKeywords = [ 'DROP', 'DELETE', 'TRUNCATE', 'ALTER', 'CREATE', 'INSERT', 'UPDATE', 'EXEC', 'EXECUTE' ]; // Simple check - in production, use proper SQL parser for (const keyword of dangerousKeywords) { const regex = new RegExp(`\\b${keyword}\\b`, 'i'); if (regex.test(sql)) { // Check if it's in a string literal (basic check) const beforeKeyword = sql.substring(0, sql.search(regex)); const singleQuotes = (beforeKeyword.match(/'/g) || []).length; const doubleQuotes = (beforeKeyword.match(/"/g) || []).length; if (singleQuotes % 2 === 0 && doubleQuotes % 2 === 0) { return { safe: false, reason: `Dangerous keyword "${keyword}" detected`, suspicious: keyword }; } } } // Check for comment injection if (sql.includes('--') || sql.includes('/*')) { return { safe: false, reason: 'SQL comments detected', suspicious: sql.includes('--') ? '--' : '/*' }; } return { safe: true }; } checkUnsafeFunctions(sql) { const unsafeFunctions = []; const upperSql = sql.toUpperCase(); // List of SQLite functions that could be dangerous const dangerous = [ 'LOAD_EXTENSION', 'WRITEFILE', 'READFILE', 'SYSTEM', 'EXEC', 'SHELLEXEC' ]; for (const func of dangerous) { if (upperSql.includes(func + '(')) { unsafeFunctions.push(func); } } // Check for non-whitelisted JSON functions const jsonFunctionMatch = sql.match(/JSON_\w+/gi) || []; for (const func of jsonFunctionMatch) { if (!SAFE_JSON_FUNCTIONS.includes(func.toUpperCase())) { unsafeFunctions.push(func); } } return unsafeFunctions; } checkJsonPathInjection(path) { // Check for script injection in JSON paths if (path.includes('<script') || path.includes('javascript:')) { return { safe: false, reason: 'Script injection attempt' }; } // Check for excessive nesting const nestingLevel = (path.match(/\./g) || []).length; if (nestingLevel > 10) { return { safe: false, reason: 'Excessive nesting depth' }; } // Check for special characters that might break JSON path if (/[^\w\.\[\]$'"*@]/.test(path)) { return { safe: false, reason: 'Invalid characters in JSON path' }; } return { safe: true }; } calculateIntentComplexity(intent) { let complexity = 0; // Base complexity per entity complexity += 10; // Add for related entities complexity += (intent.relatedEntities?.length || 0) * 15; // Add for filters complexity += (intent.filters?.length || 0) * 5; // Add for grouping complexity += (intent.groupBy?.length || 0) * 10; // Add for aggregations complexity += (intent.aggregations?.length || 0) * 5; // Add for ordering complexity += (intent.orderBy?.length || 0) * 2; // Add for time range if (intent.timeRange) complexity += 5; return complexity; } calculateJsonataComplexity(expression) { let complexity = expression.length; // Add for nested expressions complexity += (expression.match(/\[/g) || []).length * 10; complexity += (expression.match(/\{/g) || []).length * 10; // Add for function calls complexity += (expression.match(/\$\w+\(/g) || []).length * 20; // Add for transformations complexity += (expression.match(/\|/g) || []).length * 15; return complexity; } sanitizeQuery(query) { // Add read-only pragma let sanitizedSql = query.sql; if (!sanitizedSql.includes('PRAGMA')) { sanitizedSql = `-- Read-only query\n${sanitizedSql}`; } // Ensure proper parameter binding const sanitizedParams = query.params.map(param => { if (param === null || param === undefined) return null; if (typeof param === 'string') return param.substring(0, 1000); // Limit string length if (typeof param === 'number') return param; if (typeof param === 'boolean') return param ? 1 : 0; return String(param); }); return { ...query, sql: sanitizedSql, params: sanitizedParams }; } /** * Update permissions for a specific request */ updatePermissions(permissions) { this.permissions = { ...this.permissions, ...permissions }; } } //# sourceMappingURL=QueryValidator.js.map