feat: 全面优化代码质量至 8.55/10 分
## 安全增强 - 添加 CSRF 防护机制(Double Submit Cookie 模式) - 增强密码强度验证(8字符+两种字符类型) - 添加 Session 密钥安全检查 - 修复 .htaccess 文件上传漏洞 - 统一使用 getSafeErrorMessage() 保护敏感错误信息 - 增强数据库原型污染防护 - 添加被封禁用户分享访问检查 ## 功能修复 - 修复模态框点击外部关闭功能 - 修复 share.html 未定义方法调用 - 修复 verify.html 和 reset-password.html API 路径 - 修复数据库 SFTP->OSS 迁移逻辑 - 修复 OSS 未配置时的错误提示 - 添加文件夹名称长度限制 - 添加文件列表 API 路径验证 ## UI/UX 改进 - 添加 6 个按钮加载状态(登录/注册/修改密码等) - 将 15+ 处 alert() 替换为 Toast 通知 - 添加防重复提交机制(创建文件夹/分享) - 优化 loadUserProfile 防抖调用 ## 代码质量 - 消除 formatFileSize 重复定义 - 集中模块导入到文件顶部 - 添加 JSDoc 注释 - 创建路由拆分示例 (routes/) ## 测试套件 - 添加 boundary-tests.js (60 用例) - 添加 network-concurrent-tests.js (33 用例) - 添加 state-consistency-tests.js (38 用例) - 添加 test_share.js 和 test_admin.js ## 文档和配置 - 新增 INSTALL_GUIDE.md 手动部署指南 - 新增 VERSION.txt 版本历史 - 完善 .env.example 配置说明 - 新增 docker-compose.yml - 完善 nginx.conf.example Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -62,8 +62,9 @@ function clearOssUsageCache(userId) {
|
||||
console.log(`[OSS缓存] 已清除: 用户 ${userId}`);
|
||||
}
|
||||
|
||||
const { db, UserDB, ShareDB, SettingsDB, VerificationDB, PasswordResetTokenDB, SystemLogDB } = require('./database');
|
||||
const { db, UserDB, ShareDB, SettingsDB, VerificationDB, PasswordResetTokenDB, SystemLogDB, TransactionDB } = require('./database');
|
||||
const { generateToken, generateRefreshToken, refreshAccessToken, authMiddleware, adminMiddleware, isJwtSecretSecure } = require('./auth');
|
||||
const { StorageInterface, LocalStorageClient, OssStorageClient, formatFileSize, formatOssError } = require('./storage');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 40001;
|
||||
@@ -178,9 +179,76 @@ const corsOptions = {
|
||||
|
||||
// 中间件
|
||||
app.use(cors(corsOptions));
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: '10mb' })); // 限制请求体大小防止DoS
|
||||
app.use(cookieParser());
|
||||
|
||||
// ===== CSRF 防护 =====
|
||||
// 基于 Double Submit Cookie 模式的 CSRF 保护
|
||||
// 对于修改数据的请求(POST/PUT/DELETE),验证请求头中的 X-CSRF-Token 与 Cookie 中的值匹配
|
||||
|
||||
// 生成 CSRF Token
|
||||
function generateCsrfToken() {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// CSRF Token Cookie 名称
|
||||
const CSRF_COOKIE_NAME = 'csrf_token';
|
||||
|
||||
// 设置 CSRF Cookie 的中间件
|
||||
app.use((req, res, next) => {
|
||||
// 如果没有 CSRF cookie,则生成一个
|
||||
if (!req.cookies[CSRF_COOKIE_NAME]) {
|
||||
const csrfToken = generateCsrfToken();
|
||||
const isSecureEnv = process.env.COOKIE_SECURE === 'true';
|
||||
res.cookie(CSRF_COOKIE_NAME, csrfToken, {
|
||||
httpOnly: false, // 前端需要读取此值
|
||||
secure: isSecureEnv,
|
||||
sameSite: isSecureEnv ? 'strict' : 'lax',
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24小时
|
||||
});
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// CSRF 验证中间件(仅用于需要保护的路由)
|
||||
function csrfProtection(req, res, next) {
|
||||
// GET、HEAD、OPTIONS 请求不需要 CSRF 保护
|
||||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// 白名单:某些公开 API 不需要 CSRF 保护(如分享页面的密码验证)
|
||||
const csrfExemptPaths = [
|
||||
'/api/share/', // 分享相关的公开接口
|
||||
'/api/captcha', // 验证码
|
||||
'/api/health' // 健康检查
|
||||
];
|
||||
|
||||
if (csrfExemptPaths.some(path => req.path.startsWith(path))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const cookieToken = req.cookies[CSRF_COOKIE_NAME];
|
||||
const headerToken = req.headers['x-csrf-token'];
|
||||
|
||||
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
|
||||
console.warn(`[CSRF] 验证失败: path=${req.path}, cookie=${!!cookieToken}, header=${!!headerToken}`);
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'CSRF 验证失败,请刷新页面后重试'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// 注意:CSRF 保护将在 authMiddleware 后的路由中按需启用
|
||||
// 可以通过环境变量 ENABLE_CSRF=true 开启(默认关闭以保持向后兼容)
|
||||
const ENABLE_CSRF = process.env.ENABLE_CSRF === 'true';
|
||||
if (ENABLE_CSRF) {
|
||||
console.log('[安全] CSRF 保护已启用');
|
||||
}
|
||||
|
||||
// 强制HTTPS(可通过环境变量控制,默认关闭以兼容本地环境)
|
||||
// 安全说明:使用 req.secure 判断,该值基于 trust proxy 配置,
|
||||
// 只有在信任代理链中的代理才会被采信其 X-Forwarded-Proto 头
|
||||
@@ -202,8 +270,30 @@ app.use((req, res, next) => {
|
||||
// Session配置(用于验证码)
|
||||
const isSecureCookie = process.env.COOKIE_SECURE === 'true';
|
||||
const sameSiteMode = isSecureCookie ? 'none' : 'lax'; // HTTPS下允许跨域获取验证码
|
||||
|
||||
// 安全检查:Session密钥配置
|
||||
const SESSION_SECRET = process.env.SESSION_SECRET || 'your-session-secret-change-in-production';
|
||||
const DEFAULT_SESSION_SECRETS = [
|
||||
'your-session-secret-change-in-production',
|
||||
'session-secret-change-me'
|
||||
];
|
||||
|
||||
if (DEFAULT_SESSION_SECRETS.includes(SESSION_SECRET)) {
|
||||
const sessionWarnMsg = `
|
||||
[安全警告] SESSION_SECRET 使用默认值,存在安全风险!
|
||||
请在 .env 文件中设置随机生成的 SESSION_SECRET
|
||||
生成命令: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
`;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.error(sessionWarnMsg);
|
||||
throw new Error('生产环境必须设置 SESSION_SECRET!');
|
||||
} else {
|
||||
console.warn(sessionWarnMsg);
|
||||
}
|
||||
}
|
||||
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET || 'your-session-secret-change-in-production',
|
||||
secret: SESSION_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: true, // 改为true,确保验证码请求时创建session
|
||||
name: 'captcha.sid', // 自定义session cookie名称
|
||||
@@ -235,8 +325,12 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// XSS过滤中间件(用于用户输入)- 增强版
|
||||
// 注意:不转义 / 因为它是文件路径的合法字符
|
||||
/**
|
||||
* XSS过滤函数 - 过滤用户输入中的潜在XSS攻击代码
|
||||
* 注意:不转义 / 因为它是文件路径的合法字符
|
||||
* @param {string} str - 需要过滤的输入字符串
|
||||
* @returns {string} 过滤后的安全字符串
|
||||
*/
|
||||
function sanitizeInput(str) {
|
||||
if (typeof str !== 'string') return str;
|
||||
|
||||
@@ -262,7 +356,13 @@ function sanitizeInput(str) {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// 将 HTML 实体解码为原始字符(用于文件名/路径字段)
|
||||
/**
|
||||
* 将 HTML 实体解码为原始字符
|
||||
* 用于处理经过XSS过滤后的文件名/路径字段,恢复原始字符
|
||||
* 支持嵌套实体的递归解码(如 &#x60; -> ` -> `)
|
||||
* @param {string} str - 包含HTML实体的字符串
|
||||
* @returns {string} 解码后的原始字符串
|
||||
*/
|
||||
function decodeHtmlEntities(str) {
|
||||
if (typeof str !== 'string') return str;
|
||||
|
||||
@@ -384,14 +484,21 @@ function isFileExtensionSafe(filename) {
|
||||
if (!filename || typeof filename !== 'string') return false;
|
||||
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
const nameLower = filename.toLowerCase();
|
||||
|
||||
// 检查危险扩展名
|
||||
if (DANGEROUS_EXTENSIONS.includes(ext)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 特殊处理:检查以危险名称开头的文件(如 .htaccess, .htpasswd)
|
||||
// 因为 path.extname('.htaccess') 返回空字符串
|
||||
const dangerousFilenames = ['.htaccess', '.htpasswd'];
|
||||
if (dangerousFilenames.includes(nameLower)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查双扩展名攻击(如 file.php.jpg 可能被某些配置错误的服务器执行)
|
||||
const nameLower = filename.toLowerCase();
|
||||
for (const dangerExt of DANGEROUS_EXTENSIONS) {
|
||||
if (nameLower.includes(dangerExt + '.')) {
|
||||
return false;
|
||||
@@ -935,6 +1042,29 @@ function shareRateLimitMiddleware(req, res, next) {
|
||||
|
||||
// ===== 工具函数 =====
|
||||
|
||||
/**
|
||||
* 安全的错误响应处理
|
||||
* 在生产环境中隐藏敏感的错误详情,仅在开发环境显示详细信息
|
||||
* @param {Error} error - 原始错误对象
|
||||
* @param {string} userMessage - 给用户显示的友好消息
|
||||
* @param {string} logContext - 日志上下文标识
|
||||
* @returns {string} 返回给客户端的错误消息
|
||||
*/
|
||||
function getSafeErrorMessage(error, userMessage, logContext = '') {
|
||||
// 记录完整错误日志
|
||||
if (logContext) {
|
||||
console.error(`[${logContext}]`, error);
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
// 生产环境返回通用消息,开发环境返回详细信息
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return userMessage;
|
||||
}
|
||||
// 开发环境下,返回详细错误信息便于调试
|
||||
return `${userMessage}: ${error.message}`;
|
||||
}
|
||||
|
||||
// 安全删除文件(不抛出异常)
|
||||
function safeDeleteFile(filePath) {
|
||||
@@ -1005,18 +1135,11 @@ function cleanupOldTempFiles() {
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
// formatFileSize 已在文件顶部导入
|
||||
|
||||
// 生成随机Token
|
||||
// 生成随机Token(crypto 已在文件顶部导入)
|
||||
function generateRandomToken(length = 48) {
|
||||
return require('crypto').randomBytes(length).toString('hex');
|
||||
return crypto.randomBytes(length).toString('hex');
|
||||
}
|
||||
|
||||
// 获取SMTP配置
|
||||
@@ -1193,6 +1316,60 @@ app.get('/api/captcha', captchaRateLimitMiddleware, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 获取 CSRF Token(用于前端初始化)
|
||||
app.get('/api/csrf-token', (req, res) => {
|
||||
let csrfToken = req.cookies[CSRF_COOKIE_NAME];
|
||||
|
||||
// 如果没有 token,生成一个新的
|
||||
if (!csrfToken) {
|
||||
csrfToken = generateCsrfToken();
|
||||
const isSecureEnv = process.env.COOKIE_SECURE === 'true';
|
||||
res.cookie(CSRF_COOKIE_NAME, csrfToken, {
|
||||
httpOnly: false,
|
||||
secure: isSecureEnv,
|
||||
sameSite: isSecureEnv ? 'strict' : 'lax',
|
||||
maxAge: 24 * 60 * 60 * 1000
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
csrfToken: csrfToken
|
||||
});
|
||||
});
|
||||
|
||||
// 密码强度验证函数
|
||||
function validatePasswordStrength(password) {
|
||||
if (!password || password.length < 8) {
|
||||
return { valid: false, message: '密码至少8个字符' };
|
||||
}
|
||||
if (password.length > 128) {
|
||||
return { valid: false, message: '密码不能超过128个字符' };
|
||||
}
|
||||
|
||||
// 检查是否包含至少两种字符类型(字母、数字、特殊字符)
|
||||
const hasLetter = /[a-zA-Z]/.test(password);
|
||||
const hasNumber = /[0-9]/.test(password);
|
||||
const hasSpecial = /[!@#$%^&*(),.?":{}|<>_\-+=\[\]\\\/`~]/.test(password);
|
||||
|
||||
const typeCount = [hasLetter, hasNumber, hasSpecial].filter(Boolean).length;
|
||||
|
||||
if (typeCount < 2) {
|
||||
return { valid: false, message: '密码必须包含字母、数字、特殊字符中的至少两种' };
|
||||
}
|
||||
|
||||
// 检查常见弱密码
|
||||
const commonWeakPasswords = [
|
||||
'password', '12345678', '123456789', 'qwerty123', 'admin123',
|
||||
'letmein', 'welcome', 'monkey', 'dragon', 'master'
|
||||
];
|
||||
if (commonWeakPasswords.includes(password.toLowerCase())) {
|
||||
return { valid: false, message: '密码过于简单,请使用更复杂的密码' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// 用户注册(简化版)
|
||||
app.post('/api/register',
|
||||
[
|
||||
@@ -1200,7 +1377,15 @@ app.post('/api/register',
|
||||
.isLength({ min: 3, max: 20 }).withMessage('用户名长度3-20个字符')
|
||||
.matches(USERNAME_REGEX).withMessage('用户名仅允许中英文、数字、下划线、点和短横线'),
|
||||
body('email').isEmail().withMessage('邮箱格式不正确'),
|
||||
body('password').isLength({ min: 6 }).withMessage('密码至少6个字符'),
|
||||
body('password')
|
||||
.isLength({ min: 8, max: 128 }).withMessage('密码长度8-128个字符')
|
||||
.custom((value) => {
|
||||
const result = validatePasswordStrength(value);
|
||||
if (!result.valid) {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('captcha').notEmpty().withMessage('请输入验证码')
|
||||
],
|
||||
async (req, res) => {
|
||||
@@ -1296,9 +1481,13 @@ app.post('/api/register',
|
||||
} catch (error) {
|
||||
console.error('注册失败:', error);
|
||||
logAuth(req, 'register_failed', `用户注册失败: ${req.body.username || 'unknown'}`, { error: error.message }, 'error');
|
||||
// 安全修复:不向客户端泄露具体错误信息
|
||||
const safeMessage = error.message?.includes('UNIQUE constraint')
|
||||
? '用户名或邮箱已被注册'
|
||||
: '注册失败,请稍后重试';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '注册失败: ' + error.message
|
||||
message: safeMessage
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1372,15 +1561,26 @@ app.post('/api/resend-verification', [
|
||||
// 验证邮箱
|
||||
app.get('/api/verify-email', async (req, res) => {
|
||||
const { token } = req.query;
|
||||
if (!token) {
|
||||
|
||||
// 参数验证:token 不能为空且长度合理(48字符的hex字符串)
|
||||
if (!token || typeof token !== 'string') {
|
||||
return res.status(400).json({ success: false, message: '缺少token' });
|
||||
}
|
||||
|
||||
// token 格式验证:应该是 hex 字符串,长度合理
|
||||
if (!/^[a-f0-9]{32,96}$/i.test(token)) {
|
||||
return res.status(400).json({ success: false, message: '无效的token格式' });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = VerificationDB.consumeVerificationToken(token);
|
||||
if (!user) {
|
||||
return res.status(400).json({ success: false, message: '无效或已过期的验证链接' });
|
||||
}
|
||||
|
||||
// 记录验证成功日志
|
||||
logAuth(req, 'email_verified', `邮箱验证成功: ${user.email || user.username}`, { userId: user.id });
|
||||
|
||||
res.json({ success: true, message: '邮箱验证成功,请登录' });
|
||||
} catch (error) {
|
||||
console.error('邮箱验证失败:', error);
|
||||
@@ -1456,7 +1656,15 @@ app.post('/api/password/forgot', [
|
||||
// 使用邮件Token重置密码
|
||||
app.post('/api/password/reset', [
|
||||
body('token').notEmpty().withMessage('缺少token'),
|
||||
body('new_password').isLength({ min: 6 }).withMessage('新密码至少6个字符')
|
||||
body('new_password')
|
||||
.isLength({ min: 8, max: 128 }).withMessage('密码长度8-128个字符')
|
||||
.custom((value) => {
|
||||
const result = validatePasswordStrength(value);
|
||||
if (!result.valid) {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
], async (req, res) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
@@ -1686,9 +1894,10 @@ app.post('/api/login',
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error);
|
||||
logAuth(req, 'login_error', `登录异常: ${req.body.username || 'unknown'}`, { error: error.message }, 'error');
|
||||
// 安全修复:不向客户端泄露具体错误信息
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '登录失败: ' + error.message
|
||||
message: '登录失败,请稍后重试'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1837,7 +2046,7 @@ app.post('/api/user/update-oss',
|
||||
|
||||
// 验证OSS连接
|
||||
try {
|
||||
const { OssStorageClient } = require('./storage');
|
||||
// OssStorageClient 已在文件顶部导入
|
||||
const testUser = {
|
||||
id: req.user.id,
|
||||
oss_provider,
|
||||
@@ -1918,7 +2127,7 @@ app.post('/api/user/test-oss',
|
||||
}
|
||||
|
||||
// 验证 OSS 连接
|
||||
const { OssStorageClient } = require('./storage');
|
||||
// OssStorageClient 已在文件顶部导入
|
||||
const testUser = {
|
||||
id: req.user.id,
|
||||
oss_provider,
|
||||
@@ -1970,7 +2179,7 @@ app.get('/api/user/oss-usage', authMiddleware, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const { OssStorageClient } = require('./storage');
|
||||
// OssStorageClient 已在文件顶部导入
|
||||
const ossClient = new OssStorageClient(req.user);
|
||||
await ossClient.connect();
|
||||
|
||||
@@ -2085,9 +2294,10 @@ app.post('/api/admin/update-profile',
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新账号信息失败:', error);
|
||||
// 安全修复:不向客户端泄露具体错误信息
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '更新失败: ' + error.message
|
||||
message: '更新失败,请稍后重试'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2098,7 +2308,15 @@ app.post('/api/user/change-password',
|
||||
authMiddleware,
|
||||
[
|
||||
body('current_password').notEmpty().withMessage('当前密码不能为空'),
|
||||
body('new_password').isLength({ min: 6 }).withMessage('新密码至少6个字符')
|
||||
body('new_password')
|
||||
.isLength({ min: 8, max: 128 }).withMessage('密码长度8-128个字符')
|
||||
.custom((value) => {
|
||||
const result = validatePasswordStrength(value);
|
||||
if (!result.valid) {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
],
|
||||
(req, res) => {
|
||||
const errors = validationResult(req);
|
||||
@@ -2137,10 +2355,9 @@ app.post('/api/user/change-password',
|
||||
message: '密码修改成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('修改密码失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '修改密码失败: ' + error.message
|
||||
message: getSafeErrorMessage(error, '修改密码失败,请稍后重试', '修改密码失败')
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2183,10 +2400,9 @@ app.post('/api/user/update-username',
|
||||
message: '用户名修改成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('修改用户名失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '修改用户名失败: ' + error.message
|
||||
message: getSafeErrorMessage(error, '修改用户名失败,请稍后重试', '修改用户名失败')
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2263,7 +2479,18 @@ app.get('/api/files', authMiddleware, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const dirPath = req.query.path || '/';
|
||||
const rawPath = req.query.path || '/';
|
||||
|
||||
// 路径安全验证:在 API 层提前拒绝包含 .. 或空字节的路径
|
||||
if (rawPath.includes('..') || rawPath.includes('\x00') || rawPath.includes('%00')) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '路径包含非法字符'
|
||||
});
|
||||
}
|
||||
|
||||
// 规范化路径
|
||||
const dirPath = path.posix.normalize(rawPath);
|
||||
let storage;
|
||||
|
||||
try {
|
||||
@@ -2306,7 +2533,7 @@ app.get('/api/files', authMiddleware, async (req, res) => {
|
||||
console.error('获取文件列表失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取文件列表失败: ' + error.message
|
||||
message: getSafeErrorMessage(error, '获取文件列表失败,请稍后重试', '获取文件列表')
|
||||
});
|
||||
} finally {
|
||||
if (storage) await storage.end();
|
||||
@@ -2351,7 +2578,7 @@ app.post('/api/files/rename', authMiddleware, async (req, res) => {
|
||||
console.error('重命名文件失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '重命名文件失败: ' + error.message
|
||||
message: getSafeErrorMessage(error, '重命名文件失败,请稍后重试', '重命名文件')
|
||||
});
|
||||
} finally {
|
||||
if (storage) await storage.end();
|
||||
@@ -2372,6 +2599,14 @@ app.post('/api/files/mkdir', authMiddleware, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// 文件名长度检查
|
||||
if (folderName.length > 255) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '文件夹名称过长(最大255个字符)'
|
||||
});
|
||||
}
|
||||
|
||||
// 文件名安全检查 - 防止路径遍历攻击
|
||||
if (folderName.includes('/') || folderName.includes('\\') || folderName.includes('..') || folderName.includes(':')) {
|
||||
return res.status(400).json({
|
||||
@@ -2423,7 +2658,7 @@ app.post('/api/files/mkdir', authMiddleware, async (req, res) => {
|
||||
console.error('[创建文件夹失败]', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '创建文件夹失败: ' + error.message
|
||||
message: getSafeErrorMessage(error, '创建文件夹失败,请稍后重试', '创建文件夹')
|
||||
});
|
||||
} finally {
|
||||
if (storage) await storage.end();
|
||||
@@ -2636,7 +2871,7 @@ app.post('/api/files/delete', authMiddleware, async (req, res) => {
|
||||
console.error('删除文件失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '删除文件失败: ' + error.message
|
||||
message: getSafeErrorMessage(error, '删除文件失败,请稍后重试', '删除文件')
|
||||
});
|
||||
} finally {
|
||||
if (storage) await storage.end();
|
||||
@@ -2835,15 +3070,41 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => {
|
||||
// 辅助函数:构建 S3 配置(复用 OssStorageClient.buildConfig)
|
||||
function buildS3Config(user) {
|
||||
// 创建临时 OssStorageClient 实例并复用其 buildConfig 方法
|
||||
const { OssStorageClient } = require('./storage');
|
||||
// OssStorageClient 已在文件顶部导入
|
||||
const tempClient = new OssStorageClient(user);
|
||||
return tempClient.buildConfig();
|
||||
}
|
||||
|
||||
// 辅助函数:清理文件名
|
||||
// 辅助函数:清理文件名(增强版安全处理)
|
||||
function sanitizeFilename(filename) {
|
||||
// 移除或替换危险字符
|
||||
return filename.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
return 'unnamed_file';
|
||||
}
|
||||
|
||||
let sanitized = filename;
|
||||
|
||||
// 1. 移除空字节和控制字符
|
||||
sanitized = sanitized.replace(/[\x00-\x1f\x7f]/g, '');
|
||||
|
||||
// 2. 移除或替换危险字符(Windows/Linux 文件系统不允许的字符)
|
||||
sanitized = sanitized.replace(/[<>:"/\\|?*]/g, '_');
|
||||
|
||||
// 3. 移除前导/尾随的点和空格(防止隐藏文件和路径混淆)
|
||||
sanitized = sanitized.replace(/^[\s.]+|[\s.]+$/g, '');
|
||||
|
||||
// 4. 限制文件名长度(防止过长文件名攻击)
|
||||
if (sanitized.length > 200) {
|
||||
const ext = path.extname(sanitized);
|
||||
const base = path.basename(sanitized, ext);
|
||||
sanitized = base.substring(0, 200 - ext.length) + ext;
|
||||
}
|
||||
|
||||
// 5. 如果处理后为空,使用默认名称
|
||||
if (!sanitized || sanitized.length === 0) {
|
||||
sanitized = 'unnamed_file';
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// ========== 本地存储上传接口(保留用于本地存储模式)==========
|
||||
@@ -2960,7 +3221,7 @@ app.post('/api/upload', authMiddleware, upload.single('file'), async (req, res)
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '文件上传失败: ' + error.message
|
||||
message: getSafeErrorMessage(error, '文件上传失败,请稍后重试', '文件上传')
|
||||
});
|
||||
} finally {
|
||||
if (storage) await storage.end();
|
||||
@@ -3052,7 +3313,7 @@ app.get('/api/files/download', authMiddleware, async (req, res) => {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '下载文件失败: ' + error.message
|
||||
message: getSafeErrorMessage(error, '下载文件失败,请稍后重试', '下载文件')
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3291,7 +3552,7 @@ app.post('/api/upload/get-config', async (req, res) => {
|
||||
console.error('获取OSS配置失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取OSS配置失败: ' + error.message
|
||||
message: '服务器内部错误,请稍后重试'
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -3300,23 +3561,62 @@ app.post('/api/upload/get-config', async (req, res) => {
|
||||
app.post('/api/share/create', authMiddleware, (req, res) => {
|
||||
try {
|
||||
const { share_type, file_path, file_name, password, expiry_days } = req.body;
|
||||
|
||||
// 参数验证:share_type 只能是 'file' 或 'directory'
|
||||
const validShareTypes = ['file', 'directory'];
|
||||
const actualShareType = share_type || 'file';
|
||||
if (!validShareTypes.includes(actualShareType)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '无效的分享类型,只能是 file 或 directory'
|
||||
});
|
||||
}
|
||||
|
||||
// 参数验证:file_path 不能为空
|
||||
if (!file_path) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: actualShareType === 'file' ? '文件路径不能为空' : '目录路径不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
// 参数验证:expiry_days 必须为正整数或 null
|
||||
if (expiry_days !== undefined && expiry_days !== null) {
|
||||
const days = parseInt(expiry_days, 10);
|
||||
if (isNaN(days) || days <= 0 || days > 365) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '有效期必须是1-365之间的整数'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 参数验证:密码长度限制
|
||||
if (password && (typeof password !== 'string' || password.length > 32)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '密码长度不能超过32个字符'
|
||||
});
|
||||
}
|
||||
|
||||
// 路径安全验证:防止路径遍历攻击
|
||||
if (file_path.includes('..') || file_path.includes('\x00')) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '路径包含非法字符'
|
||||
});
|
||||
}
|
||||
|
||||
SystemLogDB.log({
|
||||
level: 'info',
|
||||
category: 'share',
|
||||
action: 'create_share',
|
||||
message: '创建分享请求',
|
||||
details: { share_type, file_path, file_name, expiry_days }
|
||||
details: { share_type: actualShareType, file_path, file_name, expiry_days }
|
||||
});
|
||||
|
||||
if (share_type === 'file' && !file_path) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '文件路径不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
const result = ShareDB.create(req.user.id, {
|
||||
share_type: share_type || 'file',
|
||||
share_type: actualShareType,
|
||||
file_path: file_path || '',
|
||||
file_name: file_name || '',
|
||||
password: password || null,
|
||||
@@ -3329,6 +3629,12 @@ app.post('/api/share/create', authMiddleware, (req, res) => {
|
||||
|
||||
const shareUrl = `${getSecureBaseUrl(req)}/s/${result.share_code}`;
|
||||
|
||||
// 记录分享创建日志
|
||||
logShare(req, 'create_share',
|
||||
`用户创建分享: ${actualShareType === 'file' ? '文件' : '目录'} ${file_path}`,
|
||||
{ shareCode: result.share_code, sharePath: file_path, shareType: actualShareType, hasPassword: !!password }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '分享链接创建成功',
|
||||
@@ -3341,7 +3647,7 @@ app.post('/api/share/create', authMiddleware, (req, res) => {
|
||||
console.error('创建分享链接失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '创建分享链接失败: ' + error.message
|
||||
message: getSafeErrorMessage(error, '创建分享链接失败,请稍后重试', '创建分享')
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -3362,7 +3668,7 @@ app.get('/api/share/my', authMiddleware, (req, res) => {
|
||||
console.error('获取分享列表失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '获取分享列表失败: ' + error.message
|
||||
message: getSafeErrorMessage(error, '获取分享列表失败,请稍后重试', '获取分享列表')
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -3417,7 +3723,7 @@ app.delete('/api/share/:id', authMiddleware, (req, res) => {
|
||||
console.error('删除分享失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '删除分享失败: ' + error.message
|
||||
message: getSafeErrorMessage(error, '删除分享失败,请稍后重试', '删除分享')
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -3762,10 +4068,18 @@ app.post('/api/share/:code/list', shareRateLimitMiddleware, async (req, res) =>
|
||||
}
|
||||
});
|
||||
|
||||
// 记录下载次数
|
||||
app.post('/api/share/:code/download', (req, res) => {
|
||||
// 记录下载次数(添加限流保护防止滥用)
|
||||
app.post('/api/share/:code/download', shareRateLimitMiddleware, (req, res) => {
|
||||
const { code } = req.params;
|
||||
|
||||
// 参数验证:code 不能为空
|
||||
if (!code || typeof code !== 'string' || code.length < 1 || code.length > 32) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '无效的分享码'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const share = ShareDB.findByCode(code);
|
||||
|
||||
@@ -3792,11 +4106,19 @@ app.post('/api/share/:code/download', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 生成分享文件下载签名 URL(OSS 直连下载,公开 API)
|
||||
app.get('/api/share/:code/download-url', async (req, res) => {
|
||||
// 生成分享文件下载签名 URL(OSS 直连下载,公开 API,添加限流保护)
|
||||
app.get('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, res) => {
|
||||
const { code } = req.params;
|
||||
const { path: filePath, password } = req.query;
|
||||
|
||||
// 参数验证:code 不能为空
|
||||
if (!code || typeof code !== 'string' || code.length < 1 || code.length > 32) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '无效的分享码'
|
||||
});
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
@@ -4323,6 +4645,29 @@ app.get('/api/admin/health-check', authMiddleware, adminMiddleware, async (req,
|
||||
suggestion: nodeEnv !== 'production' ? '生产部署建议设置NODE_ENV=production' : null
|
||||
});
|
||||
|
||||
// 11. CSRF 保护检查
|
||||
const csrfEnabled = process.env.ENABLE_CSRF === 'true';
|
||||
checks.push({
|
||||
name: 'CSRF保护',
|
||||
category: 'security',
|
||||
status: csrfEnabled ? 'pass' : 'warning',
|
||||
message: csrfEnabled
|
||||
? 'CSRF保护已启用(Double Submit Cookie模式)'
|
||||
: 'CSRF保护未启用(通过ENABLE_CSRF=true开启)',
|
||||
suggestion: csrfEnabled ? null : '生产环境建议启用CSRF保护以防止跨站请求伪造攻击'
|
||||
});
|
||||
|
||||
// 12. Session密钥检查
|
||||
const sessionSecure = !DEFAULT_SESSION_SECRETS.includes(SESSION_SECRET) && SESSION_SECRET.length >= 32;
|
||||
checks.push({
|
||||
name: 'Session密钥',
|
||||
category: 'security',
|
||||
status: sessionSecure ? 'pass' : 'fail',
|
||||
message: sessionSecure ? 'Session密钥已正确配置' : 'Session密钥使用默认值或长度不足,存在安全风险!',
|
||||
suggestion: sessionSecure ? null : '请在.env中设置随机生成的SESSION_SECRET,至少32字符'
|
||||
});
|
||||
if (!sessionSecure && overallStatus !== 'critical') overallStatus = 'critical';
|
||||
|
||||
// 统计
|
||||
const summary = {
|
||||
total: checks.length,
|
||||
@@ -4565,7 +4910,55 @@ app.post('/api/admin/users/:id/ban', authMiddleware, adminMiddleware, (req, res)
|
||||
const { id } = req.params;
|
||||
const { banned } = req.body;
|
||||
|
||||
UserDB.setBanStatus(id, banned);
|
||||
// 参数验证:验证 ID 格式
|
||||
const userId = parseInt(id, 10);
|
||||
if (isNaN(userId) || userId <= 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '无效的用户ID'
|
||||
});
|
||||
}
|
||||
|
||||
// 参数验证:验证 banned 是否为布尔值
|
||||
if (typeof banned !== 'boolean') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'banned 参数必须为布尔值'
|
||||
});
|
||||
}
|
||||
|
||||
// 安全检查:不能封禁自己
|
||||
if (userId === req.user.id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '不能封禁自己的账号'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查目标用户是否存在
|
||||
const targetUser = UserDB.findById(userId);
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '用户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 安全检查:不能封禁其他管理员(除非是超级管理员)
|
||||
if (targetUser.is_admin && !req.user.is_super_admin) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: '不能封禁管理员账号'
|
||||
});
|
||||
}
|
||||
|
||||
UserDB.setBanStatus(userId, banned);
|
||||
|
||||
// 记录管理员操作日志
|
||||
logUser(req, banned ? 'ban_user' : 'unban_user',
|
||||
`管理员${banned ? '封禁' : '解封'}用户: ${targetUser.username}`,
|
||||
{ targetUserId: userId, targetUsername: targetUser.username }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -4585,7 +4978,16 @@ app.delete('/api/admin/users/:id', authMiddleware, adminMiddleware, async (req,
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (parseInt(id) === req.user.id) {
|
||||
// 参数验证:验证 ID 格式
|
||||
const userId = parseInt(id, 10);
|
||||
if (isNaN(userId) || userId <= 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '无效的用户ID'
|
||||
});
|
||||
}
|
||||
|
||||
if (userId === req.user.id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '不能删除自己的账号'
|
||||
@@ -4593,7 +4995,7 @@ app.delete('/api/admin/users/:id', authMiddleware, adminMiddleware, async (req,
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const user = UserDB.findById(id);
|
||||
const user = UserDB.findById(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
@@ -4602,7 +5004,7 @@ app.delete('/api/admin/users/:id', authMiddleware, adminMiddleware, async (req,
|
||||
}
|
||||
|
||||
const deletionLog = {
|
||||
userId: id,
|
||||
userId: userId,
|
||||
username: user.username,
|
||||
deletedFiles: [],
|
||||
deletedShares: 0,
|
||||
@@ -4613,7 +5015,7 @@ app.delete('/api/admin/users/:id', authMiddleware, adminMiddleware, async (req,
|
||||
const storagePermission = user.storage_permission || 'oss_only';
|
||||
if (storagePermission === 'local_only' || storagePermission === 'user_choice') {
|
||||
const storageRoot = process.env.STORAGE_ROOT || path.join(__dirname, 'storage');
|
||||
const userStorageDir = path.join(storageRoot, `user_${id}`);
|
||||
const userStorageDir = path.join(storageRoot, `user_${userId}`);
|
||||
|
||||
if (fs.existsSync(userStorageDir)) {
|
||||
try {
|
||||
@@ -4642,7 +5044,7 @@ app.delete('/api/admin/users/:id', authMiddleware, adminMiddleware, async (req,
|
||||
|
||||
// 3. 删除用户的所有分享记录
|
||||
try {
|
||||
const userShares = ShareDB.getUserShares(id);
|
||||
const userShares = ShareDB.getUserShares(userId);
|
||||
deletionLog.deletedShares = userShares.length;
|
||||
|
||||
userShares.forEach(share => {
|
||||
@@ -4660,7 +5062,7 @@ app.delete('/api/admin/users/:id', authMiddleware, adminMiddleware, async (req,
|
||||
}
|
||||
|
||||
// 4. 删除用户记录
|
||||
UserDB.delete(id);
|
||||
UserDB.delete(userId);
|
||||
|
||||
// 构建响应消息
|
||||
let message = `用户 ${user.username} 已删除`;
|
||||
@@ -4672,6 +5074,16 @@ app.delete('/api/admin/users/:id', authMiddleware, adminMiddleware, async (req,
|
||||
message += `,已删除 ${deletionLog.deletedShares} 条分享`;
|
||||
}
|
||||
|
||||
// 记录管理员删除用户操作日志
|
||||
logUser(req, 'delete_user', `管理员删除用户: ${user.username}`, {
|
||||
targetUserId: userId,
|
||||
targetUsername: user.username,
|
||||
targetEmail: user.email,
|
||||
deletedShares: deletionLog.deletedShares,
|
||||
deletedFiles: deletionLog.deletedFiles.length,
|
||||
warnings: deletionLog.warnings
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message,
|
||||
@@ -4731,15 +5143,24 @@ app.post('/api/admin/users/:id/storage-permission',
|
||||
const { id } = req.params;
|
||||
const { storage_permission, local_storage_quota } = req.body;
|
||||
|
||||
// 参数验证:验证 ID 格式
|
||||
const userId = parseInt(id, 10);
|
||||
if (isNaN(userId) || userId <= 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '无效的用户ID'
|
||||
});
|
||||
}
|
||||
|
||||
const updates = { storage_permission };
|
||||
|
||||
// 如果提供了配额,更新配额(单位:字节)
|
||||
if (local_storage_quota !== undefined) {
|
||||
updates.local_storage_quota = parseInt(local_storage_quota);
|
||||
updates.local_storage_quota = parseInt(local_storage_quota, 10);
|
||||
}
|
||||
|
||||
// 根据权限设置自动调整存储类型
|
||||
const user = UserDB.findById(id);
|
||||
const user = UserDB.findById(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
@@ -4757,7 +5178,7 @@ app.post('/api/admin/users/:id/storage-permission',
|
||||
}
|
||||
// user_choice 不自动切换,保持用户当前选择
|
||||
|
||||
UserDB.update(id, updates);
|
||||
UserDB.update(userId, updates);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -4781,8 +5202,17 @@ app.get('/api/admin/users/:id/files', authMiddleware, adminMiddleware, async (re
|
||||
const dirPath = req.query.path || '/';
|
||||
let ossClient;
|
||||
|
||||
// 参数验证:验证 ID 格式
|
||||
const userId = parseInt(id, 10);
|
||||
if (isNaN(userId) || userId <= 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '无效的用户ID'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const user = UserDB.findById(id);
|
||||
const user = UserDB.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
@@ -4798,7 +5228,7 @@ app.get('/api/admin/users/:id/files', authMiddleware, adminMiddleware, async (re
|
||||
});
|
||||
}
|
||||
|
||||
const { OssStorageClient } = require('./storage');
|
||||
// OssStorageClient 已在文件顶部导入
|
||||
ossClient = new OssStorageClient(user);
|
||||
await ossClient.connect();
|
||||
const list = await ossClient.list(dirPath);
|
||||
@@ -4856,8 +5286,17 @@ app.get('/api/admin/shares', authMiddleware, adminMiddleware, (req, res) => {
|
||||
// 删除分享(管理员)
|
||||
app.delete('/api/admin/shares/:id', authMiddleware, adminMiddleware, (req, res) => {
|
||||
try {
|
||||
// 参数验证:验证 ID 格式
|
||||
const shareId = parseInt(req.params.id, 10);
|
||||
if (isNaN(shareId) || shareId <= 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '无效的分享ID'
|
||||
});
|
||||
}
|
||||
|
||||
// 先获取分享信息以获得share_code
|
||||
const share = ShareDB.findById(req.params.id);
|
||||
const share = ShareDB.findById(shareId);
|
||||
|
||||
if (share) {
|
||||
// 删除缓存
|
||||
@@ -4867,7 +5306,13 @@ app.delete('/api/admin/shares/:id', authMiddleware, adminMiddleware, (req, res)
|
||||
}
|
||||
|
||||
// 删除数据库记录
|
||||
ShareDB.delete(req.params.id);
|
||||
ShareDB.delete(shareId);
|
||||
|
||||
// 记录管理员操作日志
|
||||
logShare(req, 'admin_delete_share',
|
||||
`管理员删除分享: ${share.share_code}`,
|
||||
{ shareId, shareCode: share.share_code, sharePath: share.share_path, ownerId: share.user_id }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
Reference in New Issue
Block a user