auth/src/middleware/validation.js

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,
};