UNPKG

node-red-contrib-wger

Version:

Node-RED nodes for integrating with wger workout and fitness tracker API

552 lines (509 loc) 17.2 kB
/** * @fileoverview Comprehensive input validation and sanitization utility * @module utils/input-validator * @requires validator * @requires xss * @version 1.0.0 * @author Node-RED wger contrib team */ const validator = require('validator'); const xss = require('xss'); /** * Comprehensive input validation utility with security-first design. * Provides type checking, format validation, sanitization, and protection against * common web vulnerabilities including XSS, SQL injection, and prototype pollution. * * @class InputValidator * @example * // Validate a single value * const validatedId = InputValidator.validateValue( * '123', * { type: InputValidator.TYPES.INTEGER, required: true }, * 'workoutId' * ); * * @example * // Validate entire payload * const schema = { * name: { type: InputValidator.TYPES.STRING, required: true, maxLength: 100 }, * weight: { type: InputValidator.TYPES.NUMBER, min: 0, max: 500 }, * date: { type: InputValidator.TYPES.DATE, required: true } * }; * const validated = InputValidator.validatePayload(payload, schema); */ class InputValidator { /** * Enumeration of supported validation types. * Each type corresponds to specific validation and coercion logic. * * @static * @readonly * @enum {string} */ static TYPES = { STRING: 'string', NUMBER: 'number', INTEGER: 'integer', BOOLEAN: 'boolean', DATE: 'date', EMAIL: 'email', URL: 'url', ARRAY: 'array', OBJECT: 'object', ID: 'id', ENUM: 'enum' }; /** * Pre-defined regex patterns for common validation scenarios. * These patterns enforce format requirements for various data types. * * @static * @readonly * @enum {RegExp} * @property {RegExp} ID - Alphanumeric with underscores and hyphens (1-100 chars) * @property {RegExp} ALPHANUMERIC - Letters and numbers only * @property {RegExp} USERNAME - Valid username format (3-30 chars) * @property {RegExp} SAFE_STRING - String safe for display (no script injection) * @property {RegExp} BARCODE - Standard barcode format (8-14 digits) * @property {RegExp} LANGUAGE_CODE - ISO language code (e.g., 'en' or 'en-US') */ static PATTERNS = { ID: /^[a-zA-Z0-9_-]{1,100}$/, ALPHANUMERIC: /^[a-zA-Z0-9]+$/, USERNAME: /^[a-zA-Z0-9_-]{3,30}$/, SAFE_STRING: /^[a-zA-Z0-9\s\-_.,:;!?'"()[\]{}@#$%&*+=/<>|\\]+$/, BARCODE: /^[0-9]{8,14}$/, LANGUAGE_CODE: /^[a-z]{2}(-[A-Z]{2})?$/ }; /** * Validate a single value against a schema * @param {*} value - Value to validate * @param {Object} schema - Validation schema * @param {string} fieldName - Name of the field for error messages * @returns {*} Validated and sanitized value * @throws {Error} If validation fails */ static validateValue(value, schema, fieldName) { // Handle undefined/null based on required setting if (value === undefined || value === null) { if (schema.required) { throw new Error(`Required field '${fieldName}' is missing or null`); } return schema.default !== undefined ? schema.default : value; } // Type validation const validatedValue = this.validateType(value, schema.type, fieldName); // Additional validations based on schema return this.applyValidationRules(validatedValue, schema, fieldName); } /** * Validate type and perform type coercion if safe * @param {*} value - Value to validate * @param {string} type - Expected type * @param {string} fieldName - Field name for errors * @returns {*} Type-validated value */ static validateType(value, type, fieldName) { switch (type) { case this.TYPES.STRING: if (typeof value !== 'string') { // Safe coercion for primitives if (typeof value === 'number' || typeof value === 'boolean') { return String(value); } throw new Error(`Field '${fieldName}' must be a string, got ${typeof value}`); } // Check for path traversal patterns immediately for string values if (value.includes('../') || value.includes('..\\')) { throw new Error(`Field '${fieldName}' contains invalid path traversal patterns`); } return value; case this.TYPES.NUMBER: if (typeof value === 'string') { const num = parseFloat(value); if (isNaN(num)) { throw new Error(`Field '${fieldName}' must be a valid number`); } return num; } if (typeof value !== 'number' || isNaN(value)) { throw new Error(`Field '${fieldName}' must be a number`); } return value; case this.TYPES.INTEGER: { const intValue = typeof value === 'string' ? parseInt(value, 10) : value; if (!Number.isInteger(intValue)) { throw new Error(`Field '${fieldName}' must be an integer`); } return intValue; } case this.TYPES.BOOLEAN: if (typeof value === 'string') { if (value === 'true') return true; if (value === 'false') return false; } if (typeof value !== 'boolean') { throw new Error(`Field '${fieldName}' must be a boolean`); } return value; case this.TYPES.DATE: if (value instanceof Date) { if (isNaN(value.getTime())) { throw new Error(`Field '${fieldName}' contains invalid date`); } return value.toISOString(); } if (typeof value === 'string') { const date = new Date(value); if (isNaN(date.getTime())) { throw new Error(`Field '${fieldName}' must be a valid date string`); } return value; } throw new Error(`Field '${fieldName}' must be a date or date string`); case this.TYPES.EMAIL: if (typeof value !== 'string' || !validator.isEmail(value)) { throw new Error(`Field '${fieldName}' must be a valid email address`); } return validator.normalizeEmail(value); case this.TYPES.URL: if (typeof value !== 'string' || !validator.isURL(value, { require_protocol: true })) { throw new Error(`Field '${fieldName}' must be a valid URL with protocol`); } return value; case this.TYPES.ARRAY: if (!Array.isArray(value)) { throw new Error(`Field '${fieldName}' must be an array`); } return value; case this.TYPES.OBJECT: if (typeof value !== 'object' || value === null || Array.isArray(value)) { throw new Error(`Field '${fieldName}' must be an object`); } return value; case this.TYPES.ID: { if (typeof value !== 'string' && typeof value !== 'number') { throw new Error(`Field '${fieldName}' must be a string or number ID`); } // Check for path traversal in ID values const idStrCheck = String(value); if (idStrCheck.includes('../') || idStrCheck.includes('..\\')) { throw new Error(`Field '${fieldName}' contains invalid path traversal patterns`); } // Keep numeric IDs as numbers if they're valid integers if (typeof value === 'number' && Number.isInteger(value)) { return value; } const idStr = String(value); if (!this.PATTERNS.ID.test(idStr)) { throw new Error(`Field '${fieldName}' contains invalid ID format`); } return idStr; } default: return value; } } /** * Apply additional validation rules * @param {*} value - Value to validate * @param {Object} schema - Validation schema * @param {string} fieldName - Field name for errors * @returns {*} Validated value */ static applyValidationRules(value, schema, fieldName) { // String validations if (typeof value === 'string') { // Length validation if (schema.minLength !== undefined && value.length < schema.minLength) { throw new Error(`Field '${fieldName}' must be at least ${schema.minLength} characters`); } if (schema.maxLength !== undefined && value.length > schema.maxLength) { throw new Error(`Field '${fieldName}' must be at most ${schema.maxLength} characters`); } // Pattern validation if (schema.pattern) { const pattern = typeof schema.pattern === 'string' ? this.PATTERNS[schema.pattern] : schema.pattern; if (!pattern.test(value)) { throw new Error(`Field '${fieldName}' has invalid format`); } } // Sanitization for string values if (schema.sanitize !== false) { value = this.sanitizeString(value, schema.sanitizeOptions); } // Trim whitespace by default if (schema.trim !== false) { value = value.trim(); } } // Number validations if (typeof value === 'number') { if (schema.min !== undefined && value < schema.min) { throw new Error(`Field '${fieldName}' must be at least ${schema.min}`); } if (schema.max !== undefined && value > schema.max) { throw new Error(`Field '${fieldName}' must be at most ${schema.max}`); } if (schema.positive && value <= 0) { throw new Error(`Field '${fieldName}' must be positive`); } } // Array validations if (Array.isArray(value)) { if (schema.minItems !== undefined && value.length < schema.minItems) { throw new Error(`Field '${fieldName}' must have at least ${schema.minItems} items`); } if (schema.maxItems !== undefined && value.length > schema.maxItems) { throw new Error(`Field '${fieldName}' must have at most ${schema.maxItems} items`); } // Validate array items if (schema.items) { value = value.map((item, index) => this.validateValue(item, schema.items, `${fieldName}[${index}]`) ); } } // Enum validation if (schema.enum && !schema.enum.includes(value)) { throw new Error(`Field '${fieldName}' must be one of: ${schema.enum.join(', ')}`); } // Custom validation function if (schema.validate) { const result = schema.validate(value, fieldName); if (result !== true) { throw new Error(result || `Field '${fieldName}' failed custom validation`); } } return value; } /** * Sanitize string input to prevent XSS and injection attacks * @param {string} value - String to sanitize * @param {Object} options - Sanitization options * @returns {string} Sanitized string */ static sanitizeString(value, options = {}) { // Check for path traversal patterns before any other sanitization if (value.includes('../') || value.includes('..\\')) { throw new Error('Input contains path traversal patterns'); } // Basic XSS protection let sanitized = xss(value, { whiteList: {}, // No HTML tags allowed by default stripIgnoreTag: true, stripIgnoreTagBody: ['script', 'style'], ...options }); // Remove potential SQL injection patterns more carefully // Only remove dangerous patterns, not all quotes sanitized = sanitized .replace(/';|";/g, ';') // Replace quote+semicolon combinations .replace(/--/g, '') // Remove SQL comments (all -- patterns) .replace(/\/\*.*?\*\//g, ''); // Remove C-style comments // Remove null bytes sanitized = sanitized.replace(/\0/g, ''); return sanitized; } /** * Validate an entire payload against a schema * @param {Object} payload - Payload to validate * @param {Object} schema - Validation schema for all fields * @returns {Object} Validated and sanitized payload * @throws {Error} If validation fails */ static validatePayload(payload, schema) { if (!payload || typeof payload !== 'object') { throw new Error('Payload must be an object'); } const validated = {}; // First, create a clean payload that excludes prototype pollution attempts const cleanPayload = {}; for (const [key, value] of Object.entries(payload)) { // Silently exclude prototype pollution attempts if (key !== '__proto__' && key !== 'constructor' && key !== 'prototype') { cleanPayload[key] = value; } } // Validate defined schema fields from clean payload for (const [field, fieldSchema] of Object.entries(schema)) { validated[field] = this.validateValue(cleanPayload[field], fieldSchema, field); } // Check for unexpected fields (strict mode) using clean payload if (schema._strict !== false) { const schemaFields = Object.keys(schema).filter(f => !f.startsWith('_')); const payloadFields = Object.keys(cleanPayload); const unexpectedFields = payloadFields.filter(f => !schemaFields.includes(f)); if (unexpectedFields.length > 0) { throw new Error(`Unexpected fields in payload: ${unexpectedFields.join(', ')}`); } } return validated; } /** * Create a validation middleware for operations * @param {Object} validationSchemas - Map of operation names to schemas * @returns {Function} Validation middleware */ static createValidator(validationSchemas) { return (operation, payload) => { const schema = validationSchemas[operation]; if (!schema) { // No schema defined for this operation, return as-is return payload; } try { return this.validatePayload(payload, schema); } catch (error) { throw new Error(`Validation failed for operation '${operation}': ${error.message}`); } }; } /** * Pre-configured validation schemas for commonly used fields. * These schemas can be reused across different operations to ensure consistency. * * @static * @readonly * @type {Object<string, Object>} * @property {Object} id - Required ID field schema * @property {Object} optionalId - Optional ID field schema * @property {Object} search - Search term schema (1-100 chars) * @property {Object} name - Name field schema (1-200 chars) * @property {Object} description - Description field schema (max 5000 chars) * @property {Object} limit - Pagination limit schema (1-100) * @property {Object} offset - Pagination offset schema (min 0) * @property {Object} language - ISO language code schema * @property {Object} date - Date field schema * @property {Object} weight - Weight value schema (0-1000) * @property {Object} reps - Repetitions schema (0-1000) * @property {Object} sets - Sets schema (0-100) * @property {Object} calories - Calorie value schema (0-10000) * @property {Object} protein - Protein value schema in grams (0-1000) * @property {Object} carbs - Carbohydrate value schema in grams (0-1000) * @property {Object} fat - Fat value schema in grams (0-1000) * @property {Object} barcode - Product barcode schema * @property {Object} email - Email address schema * @property {Object} url - URL schema with protocol requirement * * @example * // Reuse common schemas in your validation * const mySchema = { * ...InputValidator.COMMON_SCHEMAS.id, * customField: { type: InputValidator.TYPES.STRING, maxLength: 50 } * }; */ static COMMON_SCHEMAS = { id: { type: this.TYPES.ID, required: true, sanitize: true }, optionalId: { type: this.TYPES.ID, required: false, sanitize: true }, search: { type: this.TYPES.STRING, required: true, minLength: 1, maxLength: 100, sanitize: true }, name: { type: this.TYPES.STRING, required: true, minLength: 1, maxLength: 200, sanitize: true }, description: { type: this.TYPES.STRING, required: false, maxLength: 5000, sanitize: true }, limit: { type: this.TYPES.INTEGER, required: false, min: 1, max: 100, default: 20 }, offset: { type: this.TYPES.INTEGER, required: false, min: 0, default: 0 }, language: { type: this.TYPES.STRING, required: false, pattern: 'LANGUAGE_CODE', default: 'en' }, date: { type: this.TYPES.DATE, required: false }, weight: { type: this.TYPES.NUMBER, required: true, min: 0, max: 1000 }, reps: { type: this.TYPES.INTEGER, required: false, min: 0, max: 1000 }, sets: { type: this.TYPES.INTEGER, required: false, min: 0, max: 100 }, calories: { type: this.TYPES.NUMBER, required: false, min: 0, max: 10000 }, protein: { type: this.TYPES.NUMBER, required: false, min: 0, max: 1000 }, carbs: { type: this.TYPES.NUMBER, required: false, min: 0, max: 1000 }, fat: { type: this.TYPES.NUMBER, required: false, min: 0, max: 1000 }, barcode: { type: this.TYPES.STRING, required: true, pattern: 'BARCODE' }, email: { type: this.TYPES.EMAIL, required: true }, url: { type: this.TYPES.URL, required: false } }; } module.exports = InputValidator;