// src/middleware/validation.js // === SECURITY HARDENING: INPUT VALIDATION === // Request validation middleware using simple validation /** * Validation middleware for request bodies * Uses simple validation without external dependencies */ /** * Validate phone number format */ function validatePhone(phone) { if (!phone || typeof phone !== 'string') { return { valid: false, error: 'phone_number must be a string' }; } const trimmed = phone.trim(); if (trimmed.length < 10 || trimmed.length > 20) { return { valid: false, error: 'phone_number must be 10-20 characters' }; } return { valid: true }; } /** * Validate OTP code format */ function validateOtpCode(code) { if (!code || typeof code !== 'string') { return { valid: false, error: 'code must be a string' }; } if (!/^\d{6}$/.test(code)) { return { valid: false, error: 'code must be exactly 6 digits' }; } return { valid: true }; } /** * Validate device ID */ function validateDeviceId(deviceId) { if (deviceId === undefined || deviceId === null) { return { valid: true }; // Optional field } if (typeof deviceId !== 'string') { return { valid: false, error: 'device_id must be a string' }; } if (deviceId.length > 128) { return { valid: false, error: 'device_id must be 128 characters or less' }; } return { valid: true }; } /** * Validate and sanitize device info object * Truncates long values instead of rejecting them (safe for informational fields) */ function validateDeviceInfo(deviceInfo) { if (deviceInfo === undefined || deviceInfo === null) { return { valid: true }; // Optional field } if (typeof deviceInfo !== 'object' || Array.isArray(deviceInfo)) { return { valid: false, error: 'device_info must be an object' }; } const maxLength = 100; const fields = ['platform', 'model', 'os_version', 'app_version', 'language_code', 'timezone']; for (const field of fields) { if (deviceInfo[field] !== undefined && deviceInfo[field] !== null) { if (typeof deviceInfo[field] !== 'string') { return { valid: false, error: `device_info.${field} must be a string` }; } // Truncate long values instead of rejecting (safe for informational fields) if (deviceInfo[field].length > maxLength) { deviceInfo[field] = deviceInfo[field].substring(0, maxLength); } } } return { valid: true }; } /** * Validate refresh token */ function validateRefreshToken(refreshToken) { if (!refreshToken || typeof refreshToken !== 'string') { return { valid: false, error: 'refresh_token must be a string' }; } if (refreshToken.length > 1000) { return { valid: false, error: 'refresh_token is too long' }; } return { valid: true }; } /** * Validate request body size (prevent DoS) */ function validateBodySize(body, maxSize = 10000) { const bodyStr = JSON.stringify(body); if (bodyStr.length > maxSize) { return { valid: false, error: 'Request body too large' }; } return { valid: true }; } /** * Middleware: Validate /auth/request-otp request */ function validateRequestOtpBody(req, res, next) { const { phone_number } = req.body; // Check body size const sizeCheck = validateBodySize(req.body, 1000); if (!sizeCheck.valid) { return res.status(400).json({ error: sizeCheck.error }); } // Validate phone if (!phone_number) { return res.status(400).json({ error: 'phone_number is required' }); } const phoneCheck = validatePhone(phone_number); if (!phoneCheck.valid) { return res.status(400).json({ error: phoneCheck.error }); } next(); } /** * Middleware: Validate /auth/verify-otp request */ function validateVerifyOtpBody(req, res, next) { const { phone_number, code, device_id, device_info } = req.body; // Check body size const sizeCheck = validateBodySize(req.body, 2000); if (!sizeCheck.valid) { return res.status(400).json({ error: sizeCheck.error }); } // Validate required fields if (!phone_number) { return res.status(400).json({ error: 'phone_number is required' }); } if (!code) { return res.status(400).json({ error: 'code is required' }); } // Validate each field const phoneCheck = validatePhone(phone_number); if (!phoneCheck.valid) { return res.status(400).json({ error: phoneCheck.error }); } const codeCheck = validateOtpCode(code); if (!codeCheck.valid) { return res.status(400).json({ error: codeCheck.error }); } const deviceIdCheck = validateDeviceId(device_id); if (!deviceIdCheck.valid) { return res.status(400).json({ error: deviceIdCheck.error }); } const deviceInfoCheck = validateDeviceInfo(device_info); if (!deviceInfoCheck.valid) { return res.status(400).json({ error: deviceInfoCheck.error }); } next(); } /** * Middleware: Validate /auth/refresh request */ function validateRefreshTokenBody(req, res, next) { const { refresh_token } = req.body; // Check body size const sizeCheck = validateBodySize(req.body, 2000); if (!sizeCheck.valid) { return res.status(400).json({ error: sizeCheck.error }); } if (!refresh_token) { return res.status(400).json({ error: 'refresh_token is required' }); } const tokenCheck = validateRefreshToken(refresh_token); if (!tokenCheck.valid) { return res.status(400).json({ error: tokenCheck.error }); } next(); } /** * Middleware: Validate /auth/logout request */ function validateLogoutBody(req, res, next) { const { refresh_token } = req.body; // Check body size const sizeCheck = validateBodySize(req.body, 2000); if (!sizeCheck.valid) { return res.status(400).json({ error: sizeCheck.error }); } if (!refresh_token) { return res.status(400).json({ error: 'refresh_token is required' }); } const tokenCheck = validateRefreshToken(refresh_token); if (!tokenCheck.valid) { return res.status(400).json({ error: tokenCheck.error }); } next(); } // === VALIDATION: USER ROUTES === /** * Validate user_type enum value * Allowed values: 'seller', 'buyer', 'service_provider' (from user_type_enum in database) */ function validateUserType(userType) { if (userType === undefined || userType === null) { return { valid: true }; // Optional field } if (typeof userType !== 'string') { return { valid: false, error: 'user_type must be a string' }; } const allowedValues = ['seller', 'buyer', 'service_provider']; if (!allowedValues.includes(userType)) { return { valid: false, error: `user_type must be one of: ${allowedValues.join(', ')}` }; } return { valid: true }; } /** * Validate name string (optional, trimmed, max 100) */ function validateName(name) { if (name === undefined || name === null) { return { valid: true }; // Optional field } if (typeof name !== 'string') { return { valid: false, error: 'name must be a string' }; } const trimmed = name.trim(); if (trimmed.length > 100) { return { valid: false, error: 'name must be 100 characters or less' }; } return { valid: true }; } /** * Middleware: Validate PUT /users/me request body * Validates: name (optional string, trimmed, max 100), user_type (optional enum) * Rejects: unknown extra fields, invalid types, overly long strings */ function validateUpdateProfileBody(req, res, next) { const { name, user_type } = req.body; // Check body size const sizeCheck = validateBodySize(req.body, 1000); if (!sizeCheck.valid) { return res.status(400).json({ error: sizeCheck.error }); } // Check for unknown fields (only allow 'name' and 'user_type') const allowedFields = ['name', 'user_type']; const bodyKeys = Object.keys(req.body); const unknownFields = bodyKeys.filter(key => !allowedFields.includes(key)); if (unknownFields.length > 0) { return res.status(400).json({ error: `Unknown fields not allowed: ${unknownFields.join(', ')}` }); } // Validate name (optional) const nameCheck = validateName(name); if (!nameCheck.valid) { return res.status(400).json({ error: nameCheck.error }); } // Validate user_type (optional) const userTypeCheck = validateUserType(user_type); if (!userTypeCheck.valid) { return res.status(400).json({ error: userTypeCheck.error }); } // Trim name if provided if (name && typeof name === 'string') { req.body.name = name.trim(); } next(); } /** * Middleware: Validate DELETE /users/me/devices/:device_id param * Validates: device_id as string with max length 100 * Note: device_identifier in database is TEXT, not UUID, so we validate as string */ function validateDeviceIdParam(req, res, next) { const { device_id } = req.params; if (!device_id) { return res.status(400).json({ error: 'device_id is required' }); } if (typeof device_id !== 'string') { return res.status(400).json({ error: 'device_id must be a string' }); } if (device_id.length > 100) { return res.status(400).json({ error: 'device_id must be 100 characters or less' }); } if (device_id.length === 0) { return res.status(400).json({ error: 'device_id cannot be empty' }); } next(); } /** * Middleware: Validate POST /users/me/logout-all-other-devices request body * Validates: current_device_id (optional, same validation as device_id) * Rejects: unknown extra fields */ function validateLogoutOthersBody(req, res, next) { const { current_device_id } = req.body; // Check body size const sizeCheck = validateBodySize(req.body, 1000); if (!sizeCheck.valid) { return res.status(400).json({ error: sizeCheck.error }); } // Check for unknown fields (only allow 'current_device_id') const allowedFields = ['current_device_id']; const bodyKeys = Object.keys(req.body); const unknownFields = bodyKeys.filter(key => !allowedFields.includes(key)); if (unknownFields.length > 0) { return res.status(400).json({ error: `Unknown fields not allowed: ${unknownFields.join(', ')}` }); } // Validate current_device_id (optional, but if present must be valid) if (current_device_id !== undefined && current_device_id !== null) { if (typeof current_device_id !== 'string') { return res.status(400).json({ error: 'current_device_id must be a string' }); } if (current_device_id.length > 100) { return res.status(400).json({ error: 'current_device_id must be 100 characters or less' }); } if (current_device_id.length === 0) { return res.status(400).json({ error: 'current_device_id cannot be empty' }); } } next(); } module.exports = { validateRequestOtpBody, validateVerifyOtpBody, validateRefreshTokenBody, validateLogoutBody, // === VALIDATION: USER ROUTES === validateUpdateProfileBody, validateDeviceIdParam, validateLogoutOthersBody, };