486 lines
10 KiB
JavaScript
486 lines
10 KiB
JavaScript
/**
|
|
* Comprehensive Input Validation Utility
|
|
* Implements whitelist-based validation for all user inputs
|
|
*/
|
|
|
|
const validator = require('validator');
|
|
|
|
/**
|
|
* Validation Rules Configuration
|
|
*/
|
|
const VALIDATION_RULES = {
|
|
// User-related
|
|
username: {
|
|
minLength: 3,
|
|
maxLength: 50,
|
|
pattern: /^[a-zA-Z0-9_-]+$/,
|
|
sanitize: true
|
|
},
|
|
email: {
|
|
maxLength: 255,
|
|
sanitize: true
|
|
},
|
|
password: {
|
|
minLength: 8,
|
|
maxLength: 128
|
|
},
|
|
// Content-related
|
|
playlistName: {
|
|
minLength: 1,
|
|
maxLength: 200,
|
|
pattern: /^[a-zA-Z0-9\s\-_.,()!]+$/,
|
|
sanitize: true
|
|
},
|
|
channelName: {
|
|
minLength: 1,
|
|
maxLength: 200,
|
|
sanitize: true
|
|
},
|
|
url: {
|
|
maxLength: 2048,
|
|
protocols: ['http', 'https', 'rtmp', 'rtsp', 'udp', 'rtp']
|
|
},
|
|
// Generic text fields
|
|
description: {
|
|
maxLength: 1000,
|
|
sanitize: true
|
|
},
|
|
// File names
|
|
filename: {
|
|
maxLength: 255,
|
|
pattern: /^[a-zA-Z0-9\s\-_.,()]+$/,
|
|
sanitize: true
|
|
},
|
|
// Settings keys
|
|
settingKey: {
|
|
maxLength: 100,
|
|
pattern: /^[a-zA-Z0-9_.-]+$/
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Sanitize string input to prevent XSS
|
|
*/
|
|
function sanitizeString(str) {
|
|
if (typeof str !== 'string') return str;
|
|
|
|
// Remove HTML tags
|
|
str = str.replace(/<[^>]*>/g, '');
|
|
|
|
// Remove script-related content
|
|
str = str.replace(/javascript:/gi, '');
|
|
str = str.replace(/on\w+\s*=/gi, '');
|
|
|
|
// Escape special characters
|
|
return validator.escape(str);
|
|
}
|
|
|
|
// Export sanitizeString
|
|
module.exports.sanitizeString = sanitizeString;
|
|
|
|
/**
|
|
* Validate username
|
|
*/
|
|
function validateUsername(username) {
|
|
const errors = [];
|
|
const rules = VALIDATION_RULES.username;
|
|
|
|
if (!username || typeof username !== 'string') {
|
|
errors.push('Username is required');
|
|
return { valid: false, errors, sanitized: null };
|
|
}
|
|
|
|
const trimmed = username.trim();
|
|
|
|
if (trimmed.length < rules.minLength) {
|
|
errors.push(`Username must be at least ${rules.minLength} characters`);
|
|
}
|
|
|
|
if (trimmed.length > rules.maxLength) {
|
|
errors.push(`Username must not exceed ${rules.maxLength} characters`);
|
|
}
|
|
|
|
if (!rules.pattern.test(trimmed)) {
|
|
errors.push('Username can only contain letters, numbers, hyphens, and underscores');
|
|
}
|
|
|
|
const sanitized = rules.sanitize ? sanitizeString(trimmed) : trimmed;
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
sanitized
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate email
|
|
*/
|
|
function validateEmail(email) {
|
|
const errors = [];
|
|
const rules = VALIDATION_RULES.email;
|
|
|
|
if (!email || typeof email !== 'string') {
|
|
errors.push('Email is required');
|
|
return { valid: false, errors, sanitized: null };
|
|
}
|
|
|
|
const trimmed = email.trim().toLowerCase();
|
|
|
|
if (!validator.isEmail(trimmed)) {
|
|
errors.push('Invalid email format');
|
|
}
|
|
|
|
if (trimmed.length > rules.maxLength) {
|
|
errors.push(`Email must not exceed ${rules.maxLength} characters`);
|
|
}
|
|
|
|
const sanitized = rules.sanitize ? sanitizeString(trimmed) : trimmed;
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
sanitized
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate URL
|
|
*/
|
|
function validateUrl(url, allowLocalhost = false) {
|
|
const errors = [];
|
|
const rules = VALIDATION_RULES.url;
|
|
|
|
if (!url || typeof url !== 'string') {
|
|
errors.push('URL is required');
|
|
return { valid: false, errors, sanitized: null };
|
|
}
|
|
|
|
const trimmed = url.trim();
|
|
|
|
if (trimmed.length > rules.maxLength) {
|
|
errors.push(`URL must not exceed ${rules.maxLength} characters`);
|
|
}
|
|
|
|
// Check if URL is valid and uses allowed protocols
|
|
const options = {
|
|
protocols: rules.protocols,
|
|
require_protocol: true,
|
|
allow_underscores: true
|
|
};
|
|
|
|
if (!allowLocalhost) {
|
|
options.disallow_auth = false;
|
|
}
|
|
|
|
if (!validator.isURL(trimmed, options)) {
|
|
errors.push('Invalid URL format');
|
|
}
|
|
|
|
// Additional security checks
|
|
if (trimmed.includes('javascript:')) {
|
|
errors.push('URL contains invalid content');
|
|
}
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
sanitized: trimmed
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate playlist name
|
|
*/
|
|
function validatePlaylistName(name) {
|
|
const errors = [];
|
|
const rules = VALIDATION_RULES.playlistName;
|
|
|
|
if (!name || typeof name !== 'string') {
|
|
errors.push('Playlist name is required');
|
|
return { valid: false, errors, sanitized: null };
|
|
}
|
|
|
|
const trimmed = name.trim();
|
|
|
|
if (trimmed.length < rules.minLength) {
|
|
errors.push(`Playlist name must be at least ${rules.minLength} character`);
|
|
}
|
|
|
|
if (trimmed.length > rules.maxLength) {
|
|
errors.push(`Playlist name must not exceed ${rules.maxLength} characters`);
|
|
}
|
|
|
|
if (!rules.pattern.test(trimmed)) {
|
|
errors.push('Playlist name contains invalid characters');
|
|
}
|
|
|
|
const sanitized = rules.sanitize ? sanitizeString(trimmed) : trimmed;
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
sanitized
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate channel name
|
|
*/
|
|
function validateChannelName(name) {
|
|
const errors = [];
|
|
const rules = VALIDATION_RULES.channelName;
|
|
|
|
if (!name || typeof name !== 'string') {
|
|
errors.push('Channel name is required');
|
|
return { valid: false, errors, sanitized: null };
|
|
}
|
|
|
|
const trimmed = name.trim();
|
|
|
|
if (trimmed.length < rules.minLength) {
|
|
errors.push(`Channel name must be at least ${rules.minLength} character`);
|
|
}
|
|
|
|
if (trimmed.length > rules.maxLength) {
|
|
errors.push(`Channel name must not exceed ${rules.maxLength} characters`);
|
|
}
|
|
|
|
const sanitized = rules.sanitize ? sanitizeString(trimmed) : trimmed;
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
sanitized
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate description/text field
|
|
*/
|
|
function validateDescription(description) {
|
|
const errors = [];
|
|
const rules = VALIDATION_RULES.description;
|
|
|
|
if (!description) {
|
|
return { valid: true, errors: [], sanitized: '' };
|
|
}
|
|
|
|
if (typeof description !== 'string') {
|
|
errors.push('Description must be a string');
|
|
return { valid: false, errors, sanitized: null };
|
|
}
|
|
|
|
const trimmed = description.trim();
|
|
|
|
if (trimmed.length > rules.maxLength) {
|
|
errors.push(`Description must not exceed ${rules.maxLength} characters`);
|
|
}
|
|
|
|
const sanitized = rules.sanitize ? sanitizeString(trimmed) : trimmed;
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
sanitized
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate filename
|
|
*/
|
|
function validateFilename(filename) {
|
|
const errors = [];
|
|
const rules = VALIDATION_RULES.filename;
|
|
|
|
if (!filename || typeof filename !== 'string') {
|
|
errors.push('Filename is required');
|
|
return { valid: false, errors, sanitized: null };
|
|
}
|
|
|
|
const trimmed = filename.trim();
|
|
|
|
if (trimmed.length > rules.maxLength) {
|
|
errors.push(`Filename must not exceed ${rules.maxLength} characters`);
|
|
}
|
|
|
|
if (!rules.pattern.test(trimmed)) {
|
|
errors.push('Filename contains invalid characters');
|
|
}
|
|
|
|
// Check for path traversal attempts
|
|
if (trimmed.includes('..') || trimmed.includes('/') || trimmed.includes('\\')) {
|
|
errors.push('Filename contains invalid path characters');
|
|
}
|
|
|
|
const sanitized = rules.sanitize ? sanitizeString(trimmed) : trimmed;
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
sanitized
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate setting key
|
|
*/
|
|
function validateSettingKey(key) {
|
|
const errors = [];
|
|
const rules = VALIDATION_RULES.settingKey;
|
|
|
|
if (!key || typeof key !== 'string') {
|
|
errors.push('Setting key is required');
|
|
return { valid: false, errors, sanitized: null };
|
|
}
|
|
|
|
const trimmed = key.trim();
|
|
|
|
if (trimmed.length > rules.maxLength) {
|
|
errors.push(`Setting key must not exceed ${rules.maxLength} characters`);
|
|
}
|
|
|
|
if (!rules.pattern.test(trimmed)) {
|
|
errors.push('Setting key contains invalid characters');
|
|
}
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
sanitized: trimmed
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate integer
|
|
*/
|
|
function validateInteger(value, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) {
|
|
const errors = [];
|
|
|
|
const num = parseInt(value, 10);
|
|
|
|
if (isNaN(num)) {
|
|
errors.push('Must be a valid integer');
|
|
return { valid: false, errors, sanitized: null };
|
|
}
|
|
|
|
if (num < min) {
|
|
errors.push(`Must be at least ${min}`);
|
|
}
|
|
|
|
if (num > max) {
|
|
errors.push(`Must not exceed ${max}`);
|
|
}
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
sanitized: num
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate boolean
|
|
*/
|
|
function validateBoolean(value) {
|
|
if (typeof value === 'boolean') {
|
|
return { valid: true, errors: [], sanitized: value };
|
|
}
|
|
|
|
if (value === 'true' || value === '1' || value === 1) {
|
|
return { valid: true, errors: [], sanitized: true };
|
|
}
|
|
|
|
if (value === 'false' || value === '0' || value === 0) {
|
|
return { valid: true, errors: [], sanitized: false };
|
|
}
|
|
|
|
return {
|
|
valid: false,
|
|
errors: ['Must be a valid boolean'],
|
|
sanitized: null
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate JSON
|
|
*/
|
|
function validateJSON(value, maxSize = 10000) {
|
|
const errors = [];
|
|
|
|
if (typeof value === 'object') {
|
|
const jsonString = JSON.stringify(value);
|
|
if (jsonString.length > maxSize) {
|
|
errors.push(`JSON data exceeds maximum size of ${maxSize} characters`);
|
|
}
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
sanitized: value
|
|
};
|
|
}
|
|
|
|
if (typeof value !== 'string') {
|
|
errors.push('Must be valid JSON');
|
|
return { valid: false, errors, sanitized: null };
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
if (value.length > maxSize) {
|
|
errors.push(`JSON data exceeds maximum size of ${maxSize} characters`);
|
|
}
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
sanitized: parsed
|
|
};
|
|
} catch (e) {
|
|
errors.push('Invalid JSON format');
|
|
return { valid: false, errors, sanitized: null };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sanitize object with multiple fields
|
|
*/
|
|
function sanitizeObject(obj, schema) {
|
|
const sanitized = {};
|
|
const errors = {};
|
|
let hasErrors = false;
|
|
|
|
for (const [key, validator] of Object.entries(schema)) {
|
|
const value = obj[key];
|
|
const result = validator(value);
|
|
|
|
if (!result.valid) {
|
|
errors[key] = result.errors;
|
|
hasErrors = true;
|
|
} else {
|
|
sanitized[key] = result.sanitized;
|
|
}
|
|
}
|
|
|
|
return {
|
|
valid: !hasErrors,
|
|
errors,
|
|
sanitized
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
validateUsername,
|
|
validateEmail,
|
|
validateUrl,
|
|
validatePlaylistName,
|
|
validateChannelName,
|
|
validateDescription,
|
|
validateFilename,
|
|
validateSettingKey,
|
|
validateInteger,
|
|
validateBoolean,
|
|
validateJSON,
|
|
sanitizeString,
|
|
sanitizeObject,
|
|
VALIDATION_RULES
|
|
};
|