384 lines
11 KiB
JavaScript
384 lines
11 KiB
JavaScript
// 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,
|
|
};
|
|
|