@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
407 lines • 15.4 kB
JavaScript
/**
* 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