/**
* Input Sanitization Utilities
* Provides comprehensive input validation and sanitization for security
*/
import path from 'path';
/**
* Input Sanitizer class for validating and cleaning user inputs
*/
export class InputSanitizer {
constructor(options = {}) {
this.config = {
maxPathLength: options.maxPathLength || 255,
maxPromptLength: options.maxPromptLength || 5000,
maxFilenameLength: options.maxFilenameLength || 255,
allowedImageExtensions: options.allowedImageExtensions || ['.png', '.jpg', '.jpeg', '.webp', '.gif'],
allowedDirectories: options.allowedDirectories || ['output', 'input', 'temp'],
blockPatterns: options.blockPatterns || [
/\.\.(\/|\\)/, // Directory traversal
/^(\/|\\)/, // Absolute paths (when not allowed)
/\0/, // Null bytes
/<script/i, // Script tags
/javascript:/i, // JavaScript protocol
/data:text\/html/i // Data URLs with HTML
],
enableLogging: options.enableLogging !== false,
...options
};
// Pre-compile regex patterns for efficiency
this.dangerousPatterns = {
directoryTraversal: /\.\.(\/|\\)/g,
absolutePath: /^([A-Za-z]:)?[\\/]/,
nullBytes: /\0/g,
hiddenFiles: /^\./,
specialChars: /[<>:"|?*\x00-\x1f]/g, // Windows forbidden chars
multipleSlashes: /[\/\\]{2,}/g,
leadingTrailingSlash: /^[\/\\]+|[\/\\]+$/g
};
}
/**
* Sanitize a file path
* @param {string} inputPath - The path to sanitize
* @param {Object} options - Sanitization options
* @returns {Object} { safe: boolean, sanitized: string, errors: string[] }
*/
sanitizePath(inputPath, options = {}) {
const errors = [];
let sanitized = String(inputPath || '');
// Check if input is empty
if (!sanitized || sanitized.trim() === '') {
errors.push('Path cannot be empty');
return { safe: false, sanitized: '', errors };
}
// Check length
if (sanitized.length > this.config.maxPathLength) {
errors.push(`Path exceeds maximum length of ${this.config.maxPathLength} characters`);
return { safe: false, sanitized: '', errors };
}
// Check for null bytes
if (this.dangerousPatterns.nullBytes.test(sanitized)) {
errors.push('Path contains null bytes');
return { safe: false, sanitized: '', errors };
}
// Check for directory traversal
if (this.dangerousPatterns.directoryTraversal.test(sanitized)) {
errors.push('Path contains directory traversal patterns');
return { safe: false, sanitized: '', errors };
}
// Normalize the path
sanitized = path.normalize(sanitized);
// Remove leading/trailing slashes
sanitized = sanitized.replace(this.dangerousPatterns.leadingTrailingSlash, '');
// Replace multiple slashes with single slash
sanitized = sanitized.replace(this.dangerousPatterns.multipleSlashes, '/');
// Check if it's an absolute path (unless explicitly allowed)
if (!options.allowAbsolute && this.dangerousPatterns.absolutePath.test(sanitized)) {
errors.push('Absolute paths are not allowed');
return { safe: false, sanitized: '', errors };
}
// Check against block patterns
for (const pattern of this.config.blockPatterns) {
if (pattern.test(sanitized)) {
errors.push(`Path matches blocked pattern: ${pattern}`);
return { safe: false, sanitized: '', errors };
}
}
// Validate directory restrictions
if (options.restrictToDirectories || this.config.allowedDirectories.length > 0) {
const parts = sanitized.split(path.sep);
const firstDir = parts[0];
const allowedDirs = options.restrictToDirectories || this.config.allowedDirectories;
if (!allowedDirs.includes(firstDir)) {
errors.push(`Path must be within allowed directories: ${allowedDirs.join(', ')}`);
return { safe: false, sanitized: '', errors };
}
}
// Check file extension if it's a file path
if (options.checkExtension) {
const ext = path.extname(sanitized).toLowerCase();
if (ext && !this.config.allowedImageExtensions.includes(ext)) {
errors.push(`File extension '${ext}' is not allowed. Allowed: ${this.config.allowedImageExtensions.join(', ')}`);
return { safe: false, sanitized: '', errors };
}
}
// Additional security: ensure path doesn't escape the base directory
if (options.basePath) {
const resolvedPath = path.resolve(options.basePath, sanitized);
const resolvedBase = path.resolve(options.basePath);
if (!resolvedPath.startsWith(resolvedBase)) {
errors.push('Path would escape the base directory');
return { safe: false, sanitized: '', errors };
}
}
if (this.config.enableLogging && errors.length > 0) {
console.warn('Path sanitization failed:', errors);
}
return {
safe: errors.length === 0,
sanitized,
errors
};
}
/**
* Sanitize a filename
* @param {string} filename - The filename to sanitize
* @returns {Object} { safe: boolean, sanitized: string, errors: string[] }
*/
sanitizeFilename(filename) {
const errors = [];
let sanitized = String(filename || '');
// Check if empty
if (!sanitized || sanitized.trim() === '') {
errors.push('Filename cannot be empty');
return { safe: false, sanitized: '', errors };
}
// Check length
if (sanitized.length > this.config.maxFilenameLength) {
errors.push(`Filename exceeds maximum length of ${this.config.maxFilenameLength} characters`);
sanitized = sanitized.substring(0, this.config.maxFilenameLength);
}
// Remove directory separators
sanitized = sanitized.replace(/[\/\\]/g, '');
// Remove special characters that could cause issues
sanitized = sanitized.replace(this.dangerousPatterns.specialChars, '_');
// Remove leading dots (hidden files)
sanitized = sanitized.replace(/^\.+/, '');
// Ensure it has a valid extension
const ext = path.extname(sanitized).toLowerCase();
if (!ext || !this.config.allowedImageExtensions.includes(ext)) {
// Add default extension if missing or invalid
sanitized = sanitized.replace(/\.[^.]*$/, '') + '.png';
}
return {
safe: errors.length === 0,
sanitized,
errors
};
}
/**
* Sanitize text prompt input
* @param {string} prompt - The prompt to sanitize
* @returns {Object} { safe: boolean, sanitized: string, errors: string[] }
*/
sanitizePrompt(prompt) {
const errors = [];
let sanitized = String(prompt || '');
// Check if empty
if (!sanitized || sanitized.trim() === '') {
errors.push('Prompt cannot be empty');
return { safe: false, sanitized: '', errors };
}
// Check length
if (sanitized.length > this.config.maxPromptLength) {
errors.push(`Prompt exceeds maximum length of ${this.config.maxPromptLength} characters`);
sanitized = sanitized.substring(0, this.config.maxPromptLength);
}
// Remove null bytes
sanitized = sanitized.replace(this.dangerousPatterns.nullBytes, '');
// Remove or escape potentially dangerous content
sanitized = this.escapeHtml(sanitized);
// Remove control characters except newlines and tabs
sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
// Normalize whitespace
sanitized = sanitized.replace(/\s+/g, ' ').trim();
return {
safe: errors.length === 0,
sanitized,
errors
};
}
/**
* Sanitize numeric input
* @param {any} value - The value to sanitize
* @param {Object} constraints - Min, max, default values
* @returns {Object} { safe: boolean, sanitized: number, errors: string[] }
*/
sanitizeNumber(value, constraints = {}) {
const errors = [];
const {
min = 0,
max = Number.MAX_SAFE_INTEGER,
defaultValue = 0,
allowFloat = false,
allowNegative = false
} = constraints;
let sanitized = Number(value);
// Check if it's a valid number
if (isNaN(sanitized) || !isFinite(sanitized)) {
errors.push('Invalid number value');
return { safe: false, sanitized: defaultValue, errors };
}
// Check negative numbers
if (!allowNegative && sanitized < 0) {
errors.push('Negative numbers are not allowed');
sanitized = Math.abs(sanitized);
}
// Check float/integer
if (!allowFloat && !Number.isInteger(sanitized)) {
sanitized = Math.round(sanitized);
}
// Apply constraints
if (sanitized < min) {
errors.push(`Value is below minimum of ${min}`);
sanitized = min;
}
if (sanitized > max) {
errors.push(`Value exceeds maximum of ${max}`);
sanitized = max;
}
return {
safe: errors.length === 0,
sanitized,
errors
};
}
/**
* Escape HTML special characters
*/
escapeHtml(text) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
'/': '/'
};
return text.replace(/[&<>"'/]/g, char => map[char]);
}
/**
* Validate and sanitize complete request parameters
* @param {string} operation - The operation type
* @param {Object} params - The parameters to validate
* @returns {Object} { valid: boolean, sanitized: Object, errors: string[] }
*/
sanitizeRequest(operation, params) {
const errors = [];
const sanitized = {};
switch (operation) {
case 'generate_image':
// Sanitize prompt
if (params.prompt) {
const promptResult = this.sanitizePrompt(params.prompt);
if (!promptResult.safe) {
errors.push(...promptResult.errors);
}
sanitized.prompt = promptResult.sanitized;
} else {
errors.push('Prompt is required');
}
// Sanitize negative prompt if provided
if (params.negative_prompt) {
const negPromptResult = this.sanitizePrompt(params.negative_prompt);
sanitized.negative_prompt = negPromptResult.sanitized;
}
// Sanitize numeric parameters
const numericParams = {
width: { min: 64, max: 2048, defaultValue: 1024 },
height: { min: 64, max: 2048, defaultValue: 1024 },
steps: { min: 1, max: 150, defaultValue: 20 },
cfg_scale: { min: 0, max: 30, defaultValue: 7, allowFloat: true },
seed: { min: -1, max: Number.MAX_SAFE_INTEGER, defaultValue: -1, allowNegative: true },
batch_size: { min: 1, max: 8, defaultValue: 1 }
};
for (const [param, constraints] of Object.entries(numericParams)) {
if (params[param] !== undefined) {
const result = this.sanitizeNumber(params[param], constraints);
if (!result.safe) {
errors.push(...result.errors.map(e => `${param}: ${e}`));
}
sanitized[param] = result.sanitized;
}
}
// Sanitize enum parameters
if (params.sampler_name) {
const allowedSamplers = ['euler', 'euler_ancestral', 'heun', 'dpm_2', 'dpm_2_ancestral', 'lms'];
if (!allowedSamplers.includes(params.sampler_name)) {
errors.push(`Invalid sampler: ${params.sampler_name}`);
sanitized.sampler_name = 'euler';
} else {
sanitized.sampler_name = params.sampler_name;
}
}
break;
case 'remove_background':
case 'upscale_image':
// Sanitize image path
const pathResult = this.sanitizePath(params.image_path, {
checkExtension: true,
restrictToDirectories: ['output', 'input']
});
if (!pathResult.safe) {
errors.push(...pathResult.errors);
}
sanitized.image_path = pathResult.sanitized;
// Sanitize boolean parameters
if (params.alpha_matting !== undefined) {
sanitized.alpha_matting = Boolean(params.alpha_matting);
}
break;
default:
// Pass through other operations with basic sanitization
Object.assign(sanitized, params);
}
return {
valid: errors.length === 0,
sanitized,
errors
};
}
}
/**
* Create a sanitizer instance with default configuration
*/
export function createSanitizer(options = {}) {
return new InputSanitizer(options);
}
/**
* Quick validation functions
*/
export const validators = {
isValidPath: (path) => {
const sanitizer = new InputSanitizer();
return sanitizer.sanitizePath(path).safe;
},
isValidFilename: (filename) => {
const sanitizer = new InputSanitizer();
return sanitizer.sanitizeFilename(filename).safe;
},
isValidPrompt: (prompt) => {
const sanitizer = new InputSanitizer();
return sanitizer.sanitizePrompt(prompt).safe;
}
};