UNPKG

voyage-and-consumption-mcp-server

Version:

Voyage and consumption management server handling vessel voyages, fuel consumption, performance monitoring, and operational data with ERP access for data extraction

350 lines 13.4 kB
/** * Database Query Sanitizer Utility * * Provides specialized sanitization functions for database queries * to prevent injection attacks and ensure query safety. * * This utility addresses critical security vulnerabilities identified * in the existing codebase where user input was directly used in * database queries without proper sanitization. */ import { logger } from '../utils/logger.js'; import { SanitizationMiddleware } from '../middleware/sanitization-middleware.js'; export class QuerySanitizer { /** * Sanitizes MongoDB filter queries * @param filter - The filter object to sanitize * @param options - Sanitization options * @returns Sanitized filter object */ static sanitizeFilter(filter, options) { const opts = { ...this.DEFAULT_OPTIONS, ...options }; return this.sanitizeObjectRecursive(filter, opts, 0); } /** * Sanitizes MongoDB projection objects * @param projection - The projection object to sanitize * @returns Sanitized projection object */ static sanitizeProjection(projection) { if (!projection || typeof projection !== 'object') { return projection; } const sanitized = {}; for (const [key, value] of Object.entries(projection)) { // Sanitize field names const sanitizedKey = this.sanitizeFieldName(key); // Only allow 0, 1, or specific projection operators if (value === 0 || value === 1) { sanitized[sanitizedKey] = value; } else if (typeof value === 'object' && value !== null) { // Handle projection operators like $slice, $elemMatch const sanitizedValue = this.sanitizeProjectionOperators(value); if (sanitizedValue !== null) { sanitized[sanitizedKey] = sanitizedValue; } } } return sanitized; } /** * Sanitizes MongoDB sort objects * @param sort - The sort object to sanitize * @returns Sanitized sort object */ static sanitizeSort(sort) { if (!sort || typeof sort !== 'object') { return sort; } const sanitized = {}; for (const [key, value] of Object.entries(sort)) { // Sanitize field names const sanitizedKey = this.sanitizeFieldName(key); // Only allow 1 (ascending) or -1 (descending) if (value === 1 || value === -1 || value === '1' || value === '-1') { sanitized[sanitizedKey] = parseInt(String(value), 10); } } return sanitized; } /** * Sanitizes update operations * @param update - The update object to sanitize * @returns Sanitized update object */ static sanitizeUpdate(update) { if (!update || typeof update !== 'object') { return update; } const sanitized = {}; const allowedUpdateOperators = [ '$set', '$unset', '$inc', '$mul', '$rename', '$min', '$max', '$addToSet', '$push', '$pull', '$pullAll', '$pop' ]; for (const [operator, value] of Object.entries(update)) { // Check if operator is allowed if (allowedUpdateOperators.includes(operator)) { sanitized[operator] = this.sanitizeUpdateValue(value, operator); } else { logger.warn(`Dangerous update operator removed: ${operator}`); } } return sanitized; } /** * Sanitizes Typesense query parameters * @param query - The Typesense query object * @returns Sanitized query object */ static sanitizeTypesenseQuery(query) { if (!query || typeof query !== 'object') { return query; } return SanitizationMiddleware.sanitizeTypesenseQuery(query); } /** * Sanitizes coordinate values for geo queries * @param coordinates - Array of [longitude, latitude] or object with lat/lng * @returns Sanitized coordinates */ static sanitizeCoordinates(coordinates) { if (Array.isArray(coordinates)) { if (coordinates.length !== 2) { throw new Error('Coordinates array must have exactly 2 elements [longitude, latitude]'); } const [lng, lat] = coordinates; return [ SanitizationMiddleware.sanitizeCoordinate(lng, 'longitude'), SanitizationMiddleware.sanitizeCoordinate(lat, 'latitude') ]; } if (typeof coordinates === 'object' && coordinates !== null) { const sanitized = {}; if (coordinates.latitude !== undefined) { sanitized.latitude = SanitizationMiddleware.sanitizeCoordinate(coordinates.latitude, 'latitude'); } if (coordinates.longitude !== undefined) { sanitized.longitude = SanitizationMiddleware.sanitizeCoordinate(coordinates.longitude, 'longitude'); } if (coordinates.lat !== undefined) { sanitized.lat = SanitizationMiddleware.sanitizeCoordinate(coordinates.lat, 'latitude'); } if (coordinates.lng !== undefined) { sanitized.lng = SanitizationMiddleware.sanitizeCoordinate(coordinates.lng, 'longitude'); } return sanitized; } throw new Error('Invalid coordinates format'); } /** * Recursively sanitizes object properties */ static sanitizeObjectRecursive(obj, options, depth) { if (depth >= (options.maxDepth || 5)) { logger.warn(`Maximum recursion depth reached: ${depth}`); return {}; } if (obj === null || obj === undefined) { return obj; } if (typeof obj !== 'object') { return this.sanitizeValue(obj, options); } if (Array.isArray(obj)) { return obj.map(item => this.sanitizeObjectRecursive(item, options, depth + 1)); } const sanitized = {}; for (const [key, value] of Object.entries(obj)) { // Check for dangerous operators if (key.startsWith('$') && !this.isAllowedOperator(key, options.allowedOperators)) { logger.warn(`Dangerous operator removed: ${key}`); continue; } // Sanitize field names const sanitizedKey = this.sanitizeFieldName(key); // Recursively sanitize values sanitized[sanitizedKey] = this.sanitizeObjectRecursive(value, options, depth + 1); } return sanitized; } /** * Sanitizes individual values */ static sanitizeValue(value, options) { if (typeof value === 'string') { return SanitizationMiddleware.sanitizeInput(value, { maxStringLength: options.maxStringLength, preventJavaScriptInjection: true }); } if (typeof value === 'number') { if (!Number.isFinite(value)) { throw new Error(`Invalid number value: ${value}`); } return value; } if (typeof value === 'boolean') { return value; } if (value instanceof Date) { if (isNaN(value.getTime())) { throw new Error(`Invalid date value: ${value}`); } return value; } if (value instanceof RegExp && options.allowRegex) { // Convert to safe MongoDB regex format return { $regex: SanitizationMiddleware.sanitizeRegExpPattern(value.source), $options: value.flags || 'i' }; } return value; } /** * Checks if an operator is allowed */ static isAllowedOperator(operator, allowedOperators) { if (!allowedOperators) { return this.DEFAULT_OPTIONS.allowedOperators.includes(operator); } return allowedOperators.includes(operator); } /** * Sanitizes field names */ static sanitizeFieldName(fieldName) { // Allow alphanumeric, dots, underscores, and hyphens // Remove any potentially dangerous characters return fieldName.replace(/[^\w.-]/g, ''); } /** * Sanitizes projection operators */ static sanitizeProjectionOperators(value) { if (typeof value !== 'object' || value === null) { return null; } const allowedProjectionOperators = ['$slice', '$elemMatch', '$meta']; const sanitized = {}; for (const [op, opValue] of Object.entries(value)) { if (allowedProjectionOperators.includes(op)) { sanitized[op] = this.sanitizeProjectionOperatorValue(op, opValue); } } return Object.keys(sanitized).length > 0 ? sanitized : null; } /** * Sanitizes projection operator values */ static sanitizeProjectionOperatorValue(operator, value) { switch (operator) { case '$slice': if (typeof value === 'number') { return Math.max(-1000, Math.min(1000, value)); // Limit slice range } if (Array.isArray(value) && value.length === 2) { return [ Math.max(-1000, Math.min(1000, value[0])), Math.max(0, Math.min(1000, value[1])) ]; } return 1; case '$elemMatch': return this.sanitizeFilter(value); case '$meta': // Only allow 'textScore' for now return value === 'textScore' ? 'textScore' : null; default: return null; } } /** * Sanitizes update operation values */ static sanitizeUpdateValue(value, operator) { if (operator === '$rename') { // For $rename, ensure field names are safe if (typeof value === 'object' && value !== null) { const sanitized = {}; for (const [oldField, newField] of Object.entries(value)) { sanitized[this.sanitizeFieldName(oldField)] = this.sanitizeFieldName(String(newField)); } return sanitized; } } // For other operators, sanitize the values return this.sanitizeObjectRecursive(value, this.DEFAULT_OPTIONS, 0); } /** * Validates query safety before execution * @param query - Query to validate * @returns True if safe, throws error if dangerous */ static validateQuerySafety(query) { if (!SanitizationMiddleware.validateQuerySafety(query)) { throw new Error('Query contains dangerous patterns and cannot be executed'); } return true; } /** * Creates a safe regex query for text search * @param text - Text to search for * @param field - Field to search in * @param caseInsensitive - Whether search should be case insensitive * @returns Safe regex query object */ static createSafeTextSearch(text, field, caseInsensitive = true) { const sanitizedText = SanitizationMiddleware.sanitizeInput(text, { maxStringLength: 500, preventJavaScriptInjection: true }); const safePattern = SanitizationMiddleware.sanitizeRegExpPattern(sanitizedText); const sanitizedField = this.sanitizeFieldName(field); return { [sanitizedField]: { $regex: safePattern, $options: caseInsensitive ? 'i' : '' } }; } /** * Builds safe date range queries * @param field - Date field name * @param startDate - Start date (optional) * @param endDate - End date (optional) * @returns Safe date range query */ static createSafeDateRange(field, startDate, endDate) { const sanitizedField = this.sanitizeFieldName(field); const query = {}; if (startDate) { const sanitizedStartDate = SanitizationMiddleware.sanitizeDateString(startDate); const start = new Date(sanitizedStartDate); if (isNaN(start.getTime())) { throw new Error(`Invalid start date: ${startDate}`); } query[sanitizedField] = { ...query[sanitizedField], $gte: start }; } if (endDate) { const sanitizedEndDate = SanitizationMiddleware.sanitizeDateString(endDate); const end = new Date(sanitizedEndDate); if (isNaN(end.getTime())) { throw new Error(`Invalid end date: ${endDate}`); } query[sanitizedField] = { ...query[sanitizedField], $lte: end }; } return Object.keys(query).length > 0 ? query : {}; } } QuerySanitizer.DEFAULT_OPTIONS = { allowRegex: false, maxDepth: 5, allowedOperators: ['$eq', '$gt', '$gte', '$in', '$lt', '$lte', '$ne', '$nin', '$and', '$or', '$exists'], maxStringLength: 1000 }; // Export singleton for convenience export const querySanitizer = QuerySanitizer; //# sourceMappingURL=query-sanitizer.js.map