@nvhien20vn/git-mcp-server
Version:
374 lines (373 loc) • 16.7 kB
JavaScript
import path from 'path';
import sanitizeHtml from 'sanitize-html';
import validator from 'validator';
import { BaseErrorCode, McpError } from '../../types-global/errors.js';
// Import utils from the main barrel file (logger from ../internal/logger.js)
import { logger } from '../index.js';
/**
* Sanitization class for handling various input sanitization tasks.
* Provides methods to clean and validate strings, HTML, URLs, paths, JSON, and numbers.
*/
export class Sanitization {
static instance;
/** Default list of sensitive fields for sanitizing logs */
sensitiveFields = [
'password', 'token', 'secret', 'key', 'apiKey', 'auth',
'credential', 'jwt', 'ssn', 'credit', 'card', 'cvv', 'authorization'
];
/** Default sanitize-html configuration */
defaultHtmlSanitizeConfig = {
allowedTags: [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
'li', 'b', 'i', 'strong', 'em', 'strike', 'code', 'hr', 'br',
'div', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'pre'
],
allowedAttributes: {
'a': ['href', 'name', 'target'],
'img': ['src', 'alt', 'title', 'width', 'height'],
'*': ['class', 'id', 'style']
},
preserveComments: false
};
/**
* Private constructor to enforce singleton pattern.
*/
constructor() {
// Constructor intentionally left blank for singleton.
}
/**
* Get the singleton Sanitization instance.
* @returns {Sanitization} The singleton instance.
*/
static getInstance() {
if (!Sanitization.instance) {
Sanitization.instance = new Sanitization();
}
return Sanitization.instance;
}
/**
* Set sensitive fields for log sanitization. These fields will be redacted when
* `sanitizeForLogging` is called.
* @param {string[]} fields - Array of field names to consider sensitive.
*/
setSensitiveFields(fields) {
this.sensitiveFields = [...new Set([...this.sensitiveFields, ...fields])]; // Ensure uniqueness
logger.debug('Updated sensitive fields list', { count: this.sensitiveFields.length });
}
/**
* Get the current list of sensitive fields used for log sanitization.
* @returns {string[]} Array of sensitive field names.
*/
getSensitiveFields() {
return [...this.sensitiveFields];
}
/**
* Sanitize HTML content using the `sanitize-html` library.
* Removes potentially malicious tags and attributes.
* @param {string} input - HTML string to sanitize.
* @param {HtmlSanitizeConfig} [config] - Optional custom sanitization configuration.
* @returns {string} Sanitized HTML string.
*/
sanitizeHtml(input, config) {
if (!input)
return '';
const effectiveConfig = { ...this.defaultHtmlSanitizeConfig, ...config };
const options = {
allowedTags: effectiveConfig.allowedTags,
allowedAttributes: effectiveConfig.allowedAttributes,
transformTags: effectiveConfig.transformTags
};
if (effectiveConfig.preserveComments) {
options.allowedTags = [...(options.allowedTags || []), '!--'];
}
return sanitizeHtml(input, options);
}
/**
* Sanitize string input based on context.
*
* **Important:** Using `context: 'javascript'` is explicitly disallowed and will throw an `McpError`.
* This is a security measure to prevent accidental execution or ineffective sanitization of JavaScript code.
*
* @param {string} input - String to sanitize.
* @param {SanitizeStringOptions} [options={}] - Sanitization options.
* @returns {string} Sanitized string.
* @throws {McpError} If `context: 'javascript'` is used.
*/
sanitizeString(input, options = {}) {
if (!input)
return '';
switch (options.context) {
case 'html':
return this.sanitizeHtml(input, {
allowedTags: options.allowedTags,
allowedAttributes: options.allowedAttributes ?
this.convertAttributesFormat(options.allowedAttributes) :
undefined
});
case 'attribute':
return sanitizeHtml(input, { allowedTags: [], allowedAttributes: {} });
case 'url':
if (!validator.isURL(input, { protocols: ['http', 'https'], require_protocol: true })) {
logger.warning('Invalid URL detected during string sanitization', { input });
return '';
}
return validator.trim(input);
case 'javascript':
logger.error('Attempted JavaScript sanitization via sanitizeString', { input: input.substring(0, 50) });
throw new McpError(BaseErrorCode.VALIDATION_ERROR, 'JavaScript sanitization not supported through string sanitizer');
case 'text':
default:
return sanitizeHtml(input, { allowedTags: [], allowedAttributes: {} });
}
}
/**
* Sanitize URL with robust validation.
* Ensures the URL uses allowed protocols and is well-formed.
* @param {string} input - URL to sanitize.
* @param {string[]} [allowedProtocols=['http', 'https']] - Allowed URL protocols.
* @returns {string} Sanitized URL.
* @throws {McpError} If URL is invalid or uses a disallowed protocol.
*/
sanitizeUrl(input, allowedProtocols = ['http', 'https']) {
try {
if (!validator.isURL(input, { protocols: allowedProtocols, require_protocol: true })) {
throw new Error('Invalid URL format or protocol');
}
const lowerInput = input.toLowerCase().trim();
if (lowerInput.startsWith('javascript:')) { // Double-check against javascript:
throw new Error('JavaScript protocol not allowed');
}
return validator.trim(input);
}
catch (error) {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, error instanceof Error ? error.message : 'Invalid URL format', { input });
}
}
/**
* Sanitizes a file path to prevent path traversal and other common attacks.
* Normalizes the path, optionally converts to POSIX style, and can restrict
* the path to a root directory.
*
* @param {string} input - The file path to sanitize.
* @param {PathSanitizeOptions} [options={}] - Options to control sanitization behavior.
* @returns {SanitizedPathInfo} An object containing the sanitized path and metadata about the sanitization process.
* @throws {McpError} If the path is invalid, unsafe (e.g., contains null bytes, attempts traversal).
*/
sanitizePath(input, options = {}) {
const originalInput = input;
const effectiveOptions = {
toPosix: options.toPosix ?? false,
allowAbsolute: options.allowAbsolute ?? false,
rootDir: options.rootDir
};
let wasAbsoluteInitially = false;
let convertedToRelative = false;
try {
if (!input || typeof input !== 'string') {
throw new Error('Invalid path input: must be a non-empty string');
}
let normalized = path.normalize(input);
wasAbsoluteInitially = path.isAbsolute(normalized);
if (normalized.includes('\0')) {
throw new Error('Path contains null byte');
}
if (effectiveOptions.toPosix) {
normalized = normalized.replace(/\\/g, '/');
}
if (!effectiveOptions.allowAbsolute && path.isAbsolute(normalized)) {
// Original path was absolute, but absolute paths are not allowed.
// Convert to relative by stripping leading slash or drive letter.
normalized = normalized.replace(/^(?:[A-Za-z]:)?[/\\]+/, '');
convertedToRelative = true;
}
let finalSanitizedPath;
if (effectiveOptions.rootDir) {
const rootDirResolved = path.resolve(effectiveOptions.rootDir);
// If 'normalized' is absolute (and allowed), path.resolve uses it as the base.
// If 'normalized' is relative, it's resolved against 'rootDirResolved'.
const fullPath = path.resolve(rootDirResolved, normalized);
if (!fullPath.startsWith(rootDirResolved + path.sep) && fullPath !== rootDirResolved) {
throw new Error('Path traversal detected (escapes rootDir)');
}
// Path is within rootDir, return it relative to rootDir.
finalSanitizedPath = path.relative(rootDirResolved, fullPath);
// Ensure empty string result from path.relative (if fullPath equals rootDirResolved) becomes '.'
finalSanitizedPath = finalSanitizedPath === '' ? '.' : finalSanitizedPath;
}
else { // No rootDir specified
if (path.isAbsolute(normalized)) {
if (effectiveOptions.allowAbsolute) {
// Absolute path is allowed and no rootDir to constrain it.
finalSanitizedPath = normalized;
}
else {
// Should not happen if logic above is correct (already made relative or was originally relative)
// but as a safeguard:
throw new Error('Absolute path encountered when not allowed and not rooted');
}
}
else { // Path is relative and no rootDir
if (normalized.includes('..')) {
const resolvedPath = path.resolve(normalized); // Resolves relative to CWD
const currentWorkingDir = path.resolve('.');
if (!resolvedPath.startsWith(currentWorkingDir)) {
throw new Error('Relative path traversal detected (escapes CWD)');
}
}
finalSanitizedPath = normalized;
}
}
return {
sanitizedPath: finalSanitizedPath,
originalInput,
wasAbsolute: wasAbsoluteInitially,
convertedToRelative,
optionsUsed: effectiveOptions
};
}
catch (error) {
logger.warning('Path sanitization error', {
input: originalInput,
options: effectiveOptions,
error: error instanceof Error ? error.message : String(error)
});
throw new McpError(BaseErrorCode.VALIDATION_ERROR, error instanceof Error ? error.message : 'Invalid or unsafe path', { input: originalInput } // Provide original input in error details
);
}
}
/**
* Sanitize a JSON string. Validates format and optionally checks size.
* @template T - The expected type of the parsed JSON object.
* @param {string} input - JSON string to sanitize.
* @param {number} [maxSize] - Maximum allowed size in bytes.
* @returns {T} Parsed and sanitized object.
* @throws {McpError} If JSON is invalid, too large, or input is not a string.
*/
sanitizeJson(input, maxSize) {
try {
if (typeof input !== 'string') {
throw new Error('Invalid input: expected a JSON string');
}
if (maxSize !== undefined && Buffer.byteLength(input, 'utf8') > maxSize) {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `JSON exceeds maximum allowed size of ${maxSize} bytes`, { size: Buffer.byteLength(input, 'utf8'), maxSize });
}
const parsed = JSON.parse(input);
// Optional: Add recursive sanitization of parsed object values if needed
// this.sanitizeObjectRecursively(parsed);
return parsed;
}
catch (error) {
if (error instanceof McpError)
throw error;
throw new McpError(BaseErrorCode.VALIDATION_ERROR, error instanceof Error ? error.message : 'Invalid JSON format', { input: input.length > 100 ? `${input.substring(0, 100)}...` : input });
}
}
/**
* Ensure input is a valid number and optionally within a numeric range.
* Clamps the number to the range if min/max are provided and value is outside.
* @param {number | string} input - Number or string to validate.
* @param {number} [min] - Minimum allowed value (inclusive).
* @param {number} [max] - Maximum allowed value (inclusive).
* @returns {number} Sanitized number.
* @throws {McpError} If input is not a valid number or parsable string.
*/
sanitizeNumber(input, min, max) {
let value;
if (typeof input === 'string') {
if (!validator.isNumeric(input.trim())) {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, 'Invalid number format', { input });
}
value = parseFloat(input.trim());
}
else if (typeof input === 'number') {
value = input;
}
else {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, 'Invalid input type: expected number or string', { input: String(input) });
}
if (isNaN(value) || !isFinite(value)) {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, 'Invalid number value (NaN or Infinity)', { input });
}
let clamped = false;
if (min !== undefined && value < min) {
value = min;
clamped = true;
}
if (max !== undefined && value > max) {
value = max;
clamped = true;
}
if (clamped) {
logger.debug('Number clamped to range', { input, min, max, finalValue: value });
}
return value;
}
/**
* Sanitize input for logging to protect sensitive information.
* Deep clones the input and redacts fields matching `this.sensitiveFields`.
* @param {unknown} input - Input to sanitize.
* @returns {unknown} Sanitized input safe for logging.
*/
sanitizeForLogging(input) {
try {
if (!input || typeof input !== 'object') {
return input;
}
const clonedInput = typeof structuredClone === 'function'
? structuredClone(input)
: JSON.parse(JSON.stringify(input)); // Fallback for older Node versions
this.redactSensitiveFields(clonedInput);
return clonedInput;
}
catch (error) {
logger.error('Error during log sanitization', {
error: error instanceof Error ? error.message : String(error)
});
return '[Log Sanitization Failed]';
}
}
/**
* Private helper to convert attribute format for sanitize-html.
*/
convertAttributesFormat(attrs) {
return attrs;
}
/**
* Recursively redact sensitive fields in an object or array.
* Modifies the object in place.
* @param {unknown} obj - The object or array to redact.
*/
redactSensitiveFields(obj) {
if (!obj || typeof obj !== 'object') {
return;
}
if (Array.isArray(obj)) {
obj.forEach(item => {
if (item && typeof item === 'object') {
this.redactSensitiveFields(item);
}
});
return;
}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key];
const isSensitive = this.sensitiveFields.some(field => key.toLowerCase().includes(field.toLowerCase()));
if (isSensitive) {
obj[key] = '[REDACTED]';
}
else if (value && typeof value === 'object') {
this.redactSensitiveFields(value);
}
}
}
}
}
// Create and export singleton instance
export const sanitization = Sanitization.getInstance();
/**
* Convenience function to sanitize input for logging.
* @param {unknown} input - Input to sanitize.
* @returns {unknown} Sanitized input safe for logging.
*/
export const sanitizeInputForLogging = (input) => sanitization.sanitizeForLogging(input);