mirror of
https://github.com/patdelphi/suanming.git
synced 2026-02-27 21:23:12 +08:00
feat: 完善输入验证和错误处理机制
� 核心增强: - 扩大出生日期支持范围(1800-2100年) - 增加闰年2月29日专项验证逻辑 - 精确的月份天数验证和边界检查 - 未来日期防护(允许当天,拒绝未来) �️ 安全性提升: - 新增时区格式验证(标准时区+UTC偏移) - IP地址验证(IPv4/IPv6支持) - 用户代理安全检查和长度限制 - 文件上传安全验证(类型、大小、文件名) � 边界情况处理: - 特殊日期验证(闰年逻辑) - 输入清理增强(XSS防护) - 请求头自动验证 - 恶意输入多重过滤 ✅ 测试验证: - 11个综合测试用例全部通过 - 覆盖正常输入、边界情况、恶意输入 - 闰年验证:2000年✅ 1900年❌ - XSS防护:脚本标签成功清理 � 功能完善: - 友好的错误提示信息 - 统一的验证接口 - Express中间件无缝集成 - 模块化设计便于扩展
This commit is contained in:
@@ -179,7 +179,7 @@ class InputValidator {
|
||||
this.validatePattern(birthDate, this.validationRules.date, '出生日期');
|
||||
|
||||
// 验证日期有效性
|
||||
const date = new Date(birthDate);
|
||||
const date = new Date(birthDate + 'T00:00:00.000Z');
|
||||
if (isNaN(date.getTime())) {
|
||||
throw new AppError(
|
||||
this.formatErrorMessage('invalid_date', { field: '出生日期' }),
|
||||
@@ -188,24 +188,52 @@ class InputValidator {
|
||||
);
|
||||
}
|
||||
|
||||
// 验证日期范围(1900-2100)
|
||||
// 验证日期范围(1800-2100)- 扩大范围支持更多历史日期
|
||||
const year = date.getFullYear();
|
||||
if (year < 1900 || year > 2100) {
|
||||
if (year < 1800 || year > 2100) {
|
||||
throw new AppError(
|
||||
'出生日期年份应在1900-2100年之间',
|
||||
'出生日期年份应在1800-2100年之间',
|
||||
400,
|
||||
'VALIDATION_ERROR'
|
||||
);
|
||||
}
|
||||
|
||||
// 验证不能是未来日期
|
||||
if (date > new Date()) {
|
||||
// 验证不能是未来日期(允许今天)
|
||||
const today = new Date();
|
||||
today.setHours(23, 59, 59, 999); // 设置为今天的最后一刻
|
||||
if (date > today) {
|
||||
throw new AppError(
|
||||
'出生日期不能是未来日期',
|
||||
400,
|
||||
'VALIDATION_ERROR'
|
||||
);
|
||||
}
|
||||
|
||||
// 验证月份和日期的合理性
|
||||
const [yearStr, monthStr, dayStr] = birthDate.split('-');
|
||||
const month = parseInt(monthStr, 10);
|
||||
const day = parseInt(dayStr, 10);
|
||||
|
||||
if (month < 1 || month > 12) {
|
||||
throw new AppError(
|
||||
'月份应在1-12之间',
|
||||
400,
|
||||
'VALIDATION_ERROR'
|
||||
);
|
||||
}
|
||||
|
||||
// 验证每月的天数
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
if (day < 1 || day > daysInMonth) {
|
||||
throw new AppError(
|
||||
`${year}年${month}月只有${daysInMonth}天`,
|
||||
400,
|
||||
'VALIDATION_ERROR'
|
||||
);
|
||||
}
|
||||
|
||||
// 验证特殊日期(如闰年)
|
||||
this.validateSpecialDates(birthDate);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -442,6 +470,156 @@ class InputValidator {
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证特殊日期(如闰年2月29日)
|
||||
* @param {string} birthDate 出生日期
|
||||
* @throws {AppError} 验证失败时抛出错误
|
||||
*/
|
||||
validateSpecialDates(birthDate) {
|
||||
const [yearStr, monthStr, dayStr] = birthDate.split('-');
|
||||
const year = parseInt(yearStr, 10);
|
||||
const month = parseInt(monthStr, 10);
|
||||
const day = parseInt(dayStr, 10);
|
||||
|
||||
// 验证闰年2月29日
|
||||
if (month === 2 && day === 29) {
|
||||
const isLeapYear = (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
|
||||
if (!isLeapYear) {
|
||||
throw new AppError(
|
||||
`${year}年不是闰年,2月没有29日`,
|
||||
400,
|
||||
'VALIDATION_ERROR'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证时区信息
|
||||
* @param {string} timezone 时区
|
||||
* @throws {AppError} 验证失败时抛出错误
|
||||
*/
|
||||
validateTimezone(timezone) {
|
||||
if (!timezone) return;
|
||||
|
||||
const validTimezones = [
|
||||
'Asia/Shanghai', 'Asia/Hong_Kong', 'Asia/Taipei', 'Asia/Tokyo',
|
||||
'America/New_York', 'America/Los_Angeles', 'Europe/London',
|
||||
'UTC', 'GMT', 'CST', 'EST', 'PST'
|
||||
];
|
||||
|
||||
// 支持UTC偏移格式 (+08:00, -05:00等)
|
||||
const utcOffsetPattern = /^[+-]\d{2}:\d{2}$/;
|
||||
|
||||
if (!validTimezones.includes(timezone) && !utcOffsetPattern.test(timezone)) {
|
||||
throw new AppError(
|
||||
'时区格式不正确,请使用标准时区名称或UTC偏移格式',
|
||||
400,
|
||||
'VALIDATION_ERROR'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证IP地址
|
||||
* @param {string} ip IP地址
|
||||
* @throws {AppError} 验证失败时抛出错误
|
||||
*/
|
||||
validateIP(ip) {
|
||||
if (!ip) return;
|
||||
|
||||
const ipv4Pattern = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
const ipv6Pattern = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
|
||||
|
||||
if (!ipv4Pattern.test(ip) && !ipv6Pattern.test(ip)) {
|
||||
throw new AppError(
|
||||
'IP地址格式不正确',
|
||||
400,
|
||||
'VALIDATION_ERROR'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户代理字符串
|
||||
* @param {string} userAgent 用户代理
|
||||
* @throws {AppError} 验证失败时抛出错误
|
||||
*/
|
||||
validateUserAgent(userAgent) {
|
||||
if (!userAgent) return;
|
||||
|
||||
// 检查用户代理长度和基本格式
|
||||
if (userAgent.length > 500) {
|
||||
throw new AppError(
|
||||
'用户代理字符串过长',
|
||||
400,
|
||||
'VALIDATION_ERROR'
|
||||
);
|
||||
}
|
||||
|
||||
// 检查是否包含可疑内容
|
||||
const suspiciousPatterns = [
|
||||
/<script/i, /javascript:/i, /vbscript:/i, /onload=/i, /onerror=/i
|
||||
];
|
||||
|
||||
for (const pattern of suspiciousPatterns) {
|
||||
if (pattern.test(userAgent)) {
|
||||
throw new AppError(
|
||||
'用户代理包含可疑内容',
|
||||
400,
|
||||
'VALIDATION_ERROR'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证文件上传
|
||||
* @param {Object} file 文件对象
|
||||
* @param {Array} allowedTypes 允许的文件类型
|
||||
* @param {number} maxSize 最大文件大小(字节)
|
||||
* @throws {AppError} 验证失败时抛出错误
|
||||
*/
|
||||
validateFileUpload(file, allowedTypes = [], maxSize = 5 * 1024 * 1024) {
|
||||
if (!file) {
|
||||
throw new AppError(
|
||||
'文件不能为空',
|
||||
400,
|
||||
'VALIDATION_ERROR'
|
||||
);
|
||||
}
|
||||
|
||||
// 验证文件大小
|
||||
if (file.size > maxSize) {
|
||||
throw new AppError(
|
||||
`文件大小不能超过${Math.round(maxSize / 1024 / 1024)}MB`,
|
||||
400,
|
||||
'VALIDATION_ERROR'
|
||||
);
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
if (allowedTypes.length > 0 && !allowedTypes.includes(file.mimetype)) {
|
||||
throw new AppError(
|
||||
`文件类型不支持,只允许:${allowedTypes.join(', ')}`,
|
||||
400,
|
||||
'VALIDATION_ERROR'
|
||||
);
|
||||
}
|
||||
|
||||
// 验证文件名
|
||||
if (file.originalname) {
|
||||
const dangerousChars = /[<>:"/\\|?*]/;
|
||||
if (dangerousChars.test(file.originalname)) {
|
||||
throw new AppError(
|
||||
'文件名包含非法字符',
|
||||
400,
|
||||
'VALIDATION_ERROR'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建验证中间件
|
||||
* @param {Function} validationFn 验证函数
|
||||
@@ -450,6 +628,10 @@ class InputValidator {
|
||||
createValidationMiddleware(validationFn) {
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
// 验证请求头信息
|
||||
this.validateUserAgent(req.get('User-Agent'));
|
||||
this.validateIP(req.ip || req.connection.remoteAddress);
|
||||
|
||||
// 清理输入数据
|
||||
req.body = this.sanitizeObject(req.body);
|
||||
req.query = this.sanitizeObject(req.query);
|
||||
|
||||
198
tests/input-validation-test.cjs
Normal file
198
tests/input-validation-test.cjs
Normal file
@@ -0,0 +1,198 @@
|
||||
// 输入验证功能测试
|
||||
const { inputValidator } = require('../server/utils/inputValidator.cjs');
|
||||
|
||||
console.log('=== 输入验证功能测试 ===');
|
||||
console.log('');
|
||||
|
||||
// 测试用例
|
||||
const testCases = [
|
||||
{
|
||||
name: '正常出生日期验证',
|
||||
test: () => {
|
||||
try {
|
||||
inputValidator.validateBirthDate('1990-01-15');
|
||||
console.log('✅ 正常日期验证通过');
|
||||
} catch (error) {
|
||||
console.log('❌ 正常日期验证失败:', error.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '闰年2月29日验证',
|
||||
test: () => {
|
||||
try {
|
||||
inputValidator.validateBirthDate('2000-02-29'); // 2000年是闰年
|
||||
console.log('✅ 闰年2月29日验证通过');
|
||||
} catch (error) {
|
||||
console.log('❌ 闰年2月29日验证失败:', error.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '非闰年2月29日验证',
|
||||
test: () => {
|
||||
try {
|
||||
inputValidator.validateBirthDate('1900-02-29'); // 1900年不是闰年
|
||||
console.log('❌ 非闰年2月29日应该验证失败');
|
||||
} catch (error) {
|
||||
console.log('✅ 非闰年2月29日验证正确失败:', error.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '未来日期验证',
|
||||
test: () => {
|
||||
try {
|
||||
const futureDate = new Date();
|
||||
futureDate.setFullYear(futureDate.getFullYear() + 1);
|
||||
const futureDateStr = futureDate.toISOString().split('T')[0];
|
||||
inputValidator.validateBirthDate(futureDateStr);
|
||||
console.log('❌ 未来日期应该验证失败');
|
||||
} catch (error) {
|
||||
console.log('✅ 未来日期验证正确失败:', error.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '无效月份验证',
|
||||
test: () => {
|
||||
try {
|
||||
inputValidator.validateBirthDate('1990-13-15');
|
||||
console.log('❌ 无效月份应该验证失败');
|
||||
} catch (error) {
|
||||
console.log('✅ 无效月份验证正确失败:', error.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '出生时间验证',
|
||||
test: () => {
|
||||
try {
|
||||
inputValidator.validateBirthTime('14:30');
|
||||
console.log('✅ 正常时间验证通过');
|
||||
|
||||
inputValidator.validateBirthTime('25:30');
|
||||
console.log('❌ 无效小时应该验证失败');
|
||||
} catch (error) {
|
||||
console.log('✅ 无效时间验证正确失败:', error.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '姓名验证',
|
||||
test: () => {
|
||||
try {
|
||||
inputValidator.validateName('张三');
|
||||
console.log('✅ 中文姓名验证通过');
|
||||
|
||||
inputValidator.validateName('John Smith');
|
||||
console.log('✅ 英文姓名验证通过');
|
||||
|
||||
inputValidator.validateName('张 John');
|
||||
console.log('✅ 中英文混合姓名验证通过');
|
||||
|
||||
inputValidator.validateName('<script>alert("test")</script>');
|
||||
console.log('❌ 恶意脚本应该验证失败');
|
||||
} catch (error) {
|
||||
console.log('✅ 恶意输入验证正确失败:', error.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '八字数据验证',
|
||||
test: () => {
|
||||
try {
|
||||
const validData = {
|
||||
name: '测试用户',
|
||||
birth_date: '1990-01-15',
|
||||
birth_time: '14:30',
|
||||
gender: 'male'
|
||||
};
|
||||
inputValidator.validateBaziData(validData);
|
||||
console.log('✅ 完整八字数据验证通过');
|
||||
|
||||
const invalidData = {
|
||||
name: '',
|
||||
birth_date: '1990-02-30', // 无效日期
|
||||
birth_time: '25:30', // 无效时间
|
||||
gender: 'unknown' // 无效性别
|
||||
};
|
||||
inputValidator.validateBaziData(invalidData);
|
||||
console.log('❌ 无效数据应该验证失败');
|
||||
} catch (error) {
|
||||
console.log('✅ 无效数据验证正确失败:', error.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '易经数据验证',
|
||||
test: () => {
|
||||
try {
|
||||
const validData = {
|
||||
question: '今年的事业运势如何?',
|
||||
divination_method: 'time'
|
||||
};
|
||||
inputValidator.validateYijingData(validData);
|
||||
console.log('✅ 易经数据验证通过');
|
||||
|
||||
const invalidData = {
|
||||
question: '', // 空问题
|
||||
divination_method: 'invalid_method' // 无效方法
|
||||
};
|
||||
inputValidator.validateYijingData(invalidData);
|
||||
console.log('❌ 无效易经数据应该验证失败');
|
||||
} catch (error) {
|
||||
console.log('✅ 无效易经数据验证正确失败:', error.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '输入清理测试',
|
||||
test: () => {
|
||||
const maliciousInput = '<script>alert("xss")</script>用户名';
|
||||
const cleaned = inputValidator.sanitizeInput(maliciousInput);
|
||||
console.log('原始输入:', maliciousInput);
|
||||
console.log('清理后:', cleaned);
|
||||
console.log('✅ 输入清理功能正常');
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '时区验证测试',
|
||||
test: () => {
|
||||
try {
|
||||
inputValidator.validateTimezone('Asia/Shanghai');
|
||||
console.log('✅ 标准时区验证通过');
|
||||
|
||||
inputValidator.validateTimezone('+08:00');
|
||||
console.log('✅ UTC偏移格式验证通过');
|
||||
|
||||
inputValidator.validateTimezone('Invalid/Timezone');
|
||||
console.log('❌ 无效时区应该验证失败');
|
||||
} catch (error) {
|
||||
console.log('✅ 无效时区验证正确失败:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// 运行所有测试
|
||||
console.log('开始运行测试用例...');
|
||||
console.log('');
|
||||
|
||||
testCases.forEach((testCase, index) => {
|
||||
console.log(`${index + 1}. ${testCase.name}`);
|
||||
try {
|
||||
testCase.test();
|
||||
} catch (error) {
|
||||
console.log('❌ 测试执行失败:', error.message);
|
||||
}
|
||||
console.log('');
|
||||
});
|
||||
|
||||
console.log('=== 测试完成 ===');
|
||||
console.log('');
|
||||
console.log('📊 测试总结:');
|
||||
console.log('- 输入验证功能已增强');
|
||||
console.log('- 边界情况处理完善');
|
||||
console.log('- 安全性验证加强');
|
||||
console.log('- 错误处理机制优化');
|
||||
Reference in New Issue
Block a user