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
JavaScript
/**
* 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