diff --git a/backend/database.js b/backend/database.js index 578dcd6..42b0d9f 100644 --- a/backend/database.js +++ b/backend/database.js @@ -492,16 +492,20 @@ const SettingsDB = { } }; -// 邮箱验证管理 +// 邮箱验证管理(增强安全:哈希存储) const VerificationDB = { setVerification(userId, token, expiresAtMs) { + // 对令牌进行哈希后存储 + const hashedToken = crypto.createHash('sha256').update(token).digest('hex'); db.prepare(` UPDATE users SET verification_token = ?, verification_expires_at = ?, is_verified = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ? - `).run(token, expiresAtMs, userId); + `).run(hashedToken, expiresAtMs, userId); }, consumeVerificationToken(token) { + // 对用户提供的令牌进行哈希 + const hashedToken = crypto.createHash('sha256').update(token).digest('hex'); const row = db.prepare(` SELECT * FROM users WHERE verification_token = ? @@ -512,7 +516,7 @@ const VerificationDB = { OR verification_expires_at > CURRENT_TIMESTAMP -- 兼容旧的字符串时间 ) AND is_verified = 0 - `).get(token); + `).get(hashedToken); if (!row) return null; db.prepare(` @@ -524,23 +528,30 @@ const VerificationDB = { } }; -// 密码重置 Token 管理 +// 密码重置 Token 管理(增强安全:哈希存储) const PasswordResetTokenDB = { + // 创建令牌时存储哈希值 create(userId, token, expiresAtMs) { + // 对令牌进行哈希后存储(防止数据库泄露时令牌被直接使用) + const hashedToken = crypto.createHash('sha256').update(token).digest('hex'); db.prepare(` INSERT INTO password_reset_tokens (user_id, token, expires_at, used) VALUES (?, ?, ?, 0) - `).run(userId, token, expiresAtMs); + `).run(userId, hashedToken, expiresAtMs); }, + // 验证令牌时先哈希再比较 use(token) { + // 对用户提供的令牌进行哈希 + const hashedToken = crypto.createHash('sha256').update(token).digest('hex'); const row = db.prepare(` SELECT * FROM password_reset_tokens WHERE token = ? AND used = 0 AND ( expires_at > strftime('%s','now')*1000 -- 数值时间戳 OR expires_at > CURRENT_TIMESTAMP -- 兼容旧的字符串时间 ) - `).get(token); + `).get(hashedToken); if (!row) return null; + // 立即标记为已使用(防止重复使用) db.prepare(`UPDATE password_reset_tokens SET used = 1 WHERE id = ?`).run(row.id); return row; } diff --git a/backend/server.js b/backend/server.js index bc75f91..f8ee82f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -13,11 +13,12 @@ const path = require('path'); const fs = require('fs'); const { body, validationResult } = require('express-validator'); const archiver = require('archiver'); -const { exec, execSync } = require('child_process'); +const { exec, execSync, execFile } = require('child_process'); const net = require('net'); const dns = require('dns').promises; const util = require('util'); const execAsync = util.promisify(exec); +const execFileAsync = util.promisify(execFile); const { db, UserDB, ShareDB, SettingsDB, VerificationDB, PasswordResetTokenDB } = require('./database'); const { generateToken, authMiddleware, adminMiddleware } = require('./auth'); @@ -27,6 +28,40 @@ const PORT = process.env.PORT || 40001; const USERNAME_REGEX = /^[A-Za-z0-9_.\u4e00-\u9fa5-]{3,20}$/u; // 允许中英文、数字、下划线、点和短横线 const ENFORCE_HTTPS = process.env.ENFORCE_HTTPS === 'true'; +// ===== 安全配置:公开域名白名单(防止 Host Header 注入) ===== +// 必须配置 PUBLIC_BASE_URL 环境变量,用于生成邮件链接和分享链接 +// 例如: PUBLIC_BASE_URL=https://cloud.example.com +const PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL || null; +const ALLOWED_HOSTS = process.env.ALLOWED_HOSTS + ? process.env.ALLOWED_HOSTS.split(',').map(h => h.trim().toLowerCase()) + : []; + +// 获取安全的基础URL(用于生成邮件链接、分享链接等) +function getSecureBaseUrl(req) { + // 优先使用配置的公开域名 + if (PUBLIC_BASE_URL) { + return PUBLIC_BASE_URL.replace(/\/+$/, ''); // 移除尾部斜杠 + } + + // 如果没有配置,验证 Host 头是否在白名单中 + const host = (req.get('host') || '').toLowerCase(); + if (ALLOWED_HOSTS.length > 0 && !ALLOWED_HOSTS.includes(host)) { + console.warn(`[安全警告] 检测到非白名单 Host 头: ${host}`); + // 返回第一个白名单域名作为后备 + const protocol = getProtocol(req); + return `${protocol}://${ALLOWED_HOSTS[0]}`; + } + + // 开发环境回退(仅在没有配置时使用) + if (process.env.NODE_ENV !== 'production') { + return `${getProtocol(req)}://${req.get('host')}`; + } + + // 生产环境没有配置时,记录警告并使用请求的 Host(不推荐) + console.error('[安全警告] 生产环境未配置 PUBLIC_BASE_URL,存在 Host Header 注入风险!'); + return `${getProtocol(req)}://${req.get('host')}`; +} + // 在反向代理(如 Nginx/Cloudflare)后部署时,信任代理以正确识别协议/IP/HTTPS app.set('trust proxy', process.env.TRUST_PROXY || true); @@ -126,19 +161,32 @@ app.use((req, res, next) => { next(); }); -// XSS过滤中间件(用于用户输入) +// XSS过滤中间件(用于用户输入)- 增强版 function sanitizeInput(str) { if (typeof str !== 'string') return str; - return str - .replace(/[<>'"]/g, (char) => { + + // 1. 基础HTML实体转义 + let sanitized = str + .replace(/[&<>"'\/`]/g, (char) => { const map = { + '&': '&', '<': '<', '>': '>', '"': '"', - "'": ''' + "'": ''', + '/': '/', + '`': '`' }; return map[char]; }); + + // 2. 过滤危险协议(javascript:, data:, vbscript:等) + sanitized = sanitized.replace(/(?:javascript|data|vbscript|expression|on\w+)\s*:/gi, ''); + + // 3. 移除空字节 + sanitized = sanitized.replace(/\x00/g, ''); + + return sanitized; } // HTML转义(用于模板输出) @@ -202,12 +250,44 @@ function isSafePathSegment(name) { return ( typeof name === 'string' && name.length > 0 && + name.length <= 255 && // 限制文件名长度 !name.includes('..') && !/[/\\]/.test(name) && !/[\x00-\x1F]/.test(name) ); } +// 危险文件扩展名黑名单(仅限可能被Web服务器解析执行的脚本文件) +// 注意:这是网盘应用,.exe等可执行文件允许上传(服务器不会执行) +const DANGEROUS_EXTENSIONS = [ + '.php', '.php3', '.php4', '.php5', '.phtml', '.phar', // PHP + '.jsp', '.jspx', '.jsw', '.jsv', '.jspf', // Java Server Pages + '.asp', '.aspx', '.asa', '.asax', '.ascx', '.ashx', '.asmx', // ASP.NET + '.htaccess', '.htpasswd' // Apache配置(可能改变服务器行为) +]; + +// 检查文件扩展名是否安全 +function isFileExtensionSafe(filename) { + if (!filename || typeof filename !== 'string') return false; + + const ext = path.extname(filename).toLowerCase(); + + // 检查危险扩展名 + if (DANGEROUS_EXTENSIONS.includes(ext)) { + return false; + } + + // 检查双扩展名攻击(如 file.php.jpg 可能被某些配置错误的服务器执行) + const nameLower = filename.toLowerCase(); + for (const dangerExt of DANGEROUS_EXTENSIONS) { + if (nameLower.includes(dangerExt + '.')) { + return false; + } + } + + return true; +} + // 应用XSS过滤到所有POST/PUT请求的body app.use((req, res, next) => { if ((req.method === 'POST' || req.method === 'PUT') && req.body) { @@ -541,6 +621,27 @@ const captchaLimiter = new RateLimiter({ blockDuration: 30 * 60 * 1000 }); +// 创建API密钥验证限流器(防止暴力枚举API密钥,5次失败/小时,封锁24小时) +const apiKeyLimiter = new RateLimiter({ + maxAttempts: 5, + windowMs: 60 * 60 * 1000, // 1小时窗口 + blockDuration: 24 * 60 * 60 * 1000 // 封锁24小时 +}); + +// 创建文件上传限流器(每用户每小时最多100次上传) +const uploadLimiter = new RateLimiter({ + maxAttempts: 100, + windowMs: 60 * 60 * 1000, + blockDuration: 60 * 60 * 1000 +}); + +// 创建文件列表查询限流器(每用户每分钟最多60次) +const fileListLimiter = new RateLimiter({ + maxAttempts: 60, + windowMs: 60 * 1000, + blockDuration: 5 * 60 * 1000 +}); + // 验证码最小请求间隔控制 const CAPTCHA_MIN_INTERVAL = 3000; // 3秒 const captchaLastRequest = new TTLCache(15 * 60 * 1000); // 15分钟自动清理 @@ -894,7 +995,8 @@ app.get('/api/captcha', captchaRateLimitMiddleware, (req, res) => { if (err) { console.error('[验证码] Session保存失败:', err); } else { - console.log('[验证码] 生成成功, SessionID:', req.sessionID, '验证码:', captcha.text); + // 安全:不记录验证码明文到日志 + console.log('[验证码] 生成成功, SessionID:', req.sessionID); } }); @@ -970,7 +1072,7 @@ app.post('/api/register', verification_expires_at: expiresAtMs }); - const verifyLink = `${getProtocol(req)}://${req.get('host')}/app.html?verifyToken=${verifyToken}`; + const verifyLink = `${getSecureBaseUrl(req)}/app.html?verifyToken=${verifyToken}`; try { await sendMail( @@ -1040,7 +1142,7 @@ app.post('/api/resend-verification', [ const expiresAtMs = Date.now() + 30 * 60 * 1000; VerificationDB.setVerification(user.id, verifyToken, expiresAtMs); - const verifyLink = `${getProtocol(req)}://${req.get('host')}/app.html?verifyToken=${verifyToken}`; + const verifyLink = `${getSecureBaseUrl(req)}/app.html?verifyToken=${verifyToken}`; const safeUsernameForMail = escapeHtml(user.username); await sendMail( user.email, @@ -1096,36 +1198,39 @@ app.post('/api/password/forgot', [ return res.status(400).json({ success: false, message: 'SMTP未配置,无法发送邮件' }); } + // 安全修复:无论邮箱是否存在,都返回相同的成功消息(防止邮箱枚举) const user = UserDB.findByEmail(email); - if (!user) { - return res.status(400).json({ success: false, message: '邮箱未注册,无法发送重置邮件' }); - } - if (user.is_banned || !user.is_active) { - return res.status(403).json({ success: false, message: '账号不可用,无法重置密码' }); - } - if (!user.is_verified) { - return res.status(400).json({ success: false, message: '邮箱未验证,无法重置密码' }); + + // 只有当用户存在、已验证、未封禁时才发送邮件 + if (user && user.is_verified && user.is_active && !user.is_banned) { + const token = generateRandomToken(24); + const expiresAtMs = Date.now() + 30 * 60 * 1000; + PasswordResetTokenDB.create(user.id, token, expiresAtMs); + + const resetLink = `${getSecureBaseUrl(req)}/app.html?resetToken=${token}`; + const safeUsernameForMail = escapeHtml(user.username); + + // 异步发送邮件,不等待结果(避免通过响应时间判断邮箱是否存在) + sendMail( + email, + '密码重置 - 玩玩云', + `

您好,${safeUsernameForMail}:

+

请点击下面的链接重置密码,30分钟内有效:

+

${resetLink}

+

如果不是您本人操作,请忽略此邮件。

` + ).catch(err => { + console.error('发送密码重置邮件失败:', err.message); + }); + } else { + // 记录但不暴露邮箱是否存在 + console.log('[密码重置] 邮箱不存在或账号不可用:', email); } - const token = generateRandomToken(24); - const expiresAtMs = Date.now() + 30 * 60 * 1000; - PasswordResetTokenDB.create(user.id, token, expiresAtMs); - - const resetLink = `${getProtocol(req)}://${req.get('host')}/app.html?resetToken=${token}`; - const safeUsernameForMail = escapeHtml(user.username); - await sendMail( - email, - '密码重置 - 玩玩云', - `

您好,${safeUsernameForMail}:

-

请点击下面的链接重置密码,30分钟内有效:

-

${resetLink}

-

如果不是您本人操作,请忽略此邮件。

` - ); - - res.json({ success: true, message: '重置邮件已发送,请查收邮箱完成验证' }); + // 无论邮箱是否存在,都返回相同的成功消息 + res.json({ success: true, message: '如果该邮箱已注册,您将收到密码重置链接' }); } catch (error) { const status = error.status || 500; - console.error('发送密码重置邮件失败:', error); + console.error('密码重置请求失败:', error); res.status(status).json({ success: false, message: error.message || '发送失败' }); } }); @@ -1198,7 +1303,7 @@ app.post('/api/login', // 如果需要验证码,则验证验证码 if (needCaptcha) { - console.log('[登录验证] 需要验证码, SessionID:', req.sessionID, 'IP失败次数:', ipFailures, '用户名失败次数:', usernameFailures); + console.log('[登录验证] 需要验证码, IP失败次数:', ipFailures, '用户名失败次数:', usernameFailures); if (!captcha) { return res.status(400).json({ @@ -1212,7 +1317,8 @@ app.post('/api/login', const sessionCaptcha = req.session.captcha; const captchaTime = req.session.captchaTime; - console.log('[登录验证] Session验证码:', sessionCaptcha, '输入验证码:', captcha, 'Session时间:', captchaTime); + // 安全:不记录验证码明文 + console.log('[登录验证] 正在验证验证码...'); if (!sessionCaptcha || !captchaTime) { console.log('[登录验证] 验证码不存在于Session中'); @@ -1314,11 +1420,15 @@ app.post('/api/login', } } + // 增强Cookie安全设置 + const isSecureEnv = process.env.COOKIE_SECURE === 'true'; res.cookie('token', token, { httpOnly: true, - secure: process.env.COOKIE_SECURE === 'true', // HTTPS环境下启用 - sameSite: 'lax', // 防止CSRF攻击 - maxAge: 7 * 24 * 60 * 60 * 1000 // 7天 + secure: isSecureEnv, + // HTTPS环境使用strict,HTTP环境使用lax(开发环境兼容) + sameSite: isSecureEnv ? 'strict' : 'lax', + maxAge: 7 * 24 * 60 * 60 * 1000, // 7天 + path: '/' // 限制Cookie作用域 }); res.json({ @@ -1675,8 +1785,18 @@ app.post('/api/user/switch-storage', } ); -// 获取文件列表 +// 获取文件列表(添加速率限制) app.get('/api/files', authMiddleware, async (req, res) => { + // 速率限制检查 + const rateLimitKey = `file_list:${req.user.id}`; + const rateLimitResult = fileListLimiter.recordFailure(rateLimitKey); + if (rateLimitResult.blocked) { + return res.status(429).json({ + success: false, + message: `请求过于频繁,请在 ${rateLimitResult.waitMinutes} 分钟后重试` + }); + } + const dirPath = req.query.path || '/'; let storage; @@ -1969,8 +2089,22 @@ app.post('/api/files/delete', authMiddleware, async (req, res) => { } }); -// 上传文件 +// 上传文件(添加速率限制) app.post('/api/upload', authMiddleware, upload.single('file'), async (req, res) => { + // 速率限制检查 + const rateLimitKey = `upload:${req.user.id}`; + const rateLimitResult = uploadLimiter.recordFailure(rateLimitKey); + if (rateLimitResult.blocked) { + // 清理已上传的临时文件 + if (req.file && fs.existsSync(req.file.path)) { + safeDeleteFile(req.file.path); + } + return res.status(429).json({ + success: false, + message: `上传过于频繁,请在 ${rateLimitResult.waitMinutes} 分钟后重试` + }); + } + if (!req.file) { return res.status(400).json({ success: false, @@ -1997,14 +2131,26 @@ app.post('/api/upload', authMiddleware, upload.single('file'), async (req, res) const remotePath = req.body.path || '/'; // 修复中文文件名:multer将UTF-8转为了Latin1,需要转回来 const originalFilename = Buffer.from(req.file.originalname, 'latin1').toString('utf8'); + // 文件名安全校验 if (!isSafePathSegment(originalFilename)) { + safeDeleteFile(req.file.path); return res.status(400).json({ success: false, message: '文件名包含非法字符' }); } + // 文件扩展名安全检查(防止上传危险文件) + if (!isFileExtensionSafe(originalFilename)) { + console.warn(`[安全] 拒绝上传危险文件: ${originalFilename}, 用户: ${req.user.username}`); + safeDeleteFile(req.file.path); + return res.status(400).json({ + success: false, + message: '不允许上传此类型的文件(安全限制)' + }); + } + // 路径安全校验 const normalizedPath = path.posix.normalize(remotePath || '/'); if (normalizedPath.includes('..')) { @@ -2149,7 +2295,7 @@ app.post('/api/upload/generate-tool', authMiddleware, async (req, res) => { const config = { username: req.user.username, api_key: newApiKey, - api_base_url: `${req.get('x-forwarded-proto') || req.protocol}://${req.get('host')}` + api_base_url: getSecureBaseUrl(req) }; res.json({ @@ -2184,7 +2330,7 @@ app.get('/api/upload/download-tool', authMiddleware, async (req, res) => { const config = { username: req.user.username, api_key: newApiKey, - api_base_url: `${req.get('x-forwarded-proto') || req.protocol}://${req.get('host')}` + api_base_url: getSecureBaseUrl(req) }; console.log("[上传工具配置]", JSON.stringify(config, null, 2)); @@ -2302,7 +2448,23 @@ app.get('/api/upload/download-tool', authMiddleware, async (req, res) => { }); // 通过API密钥获取SFTP配置(供Python工具调用) +// 添加速率限制防止暴力枚举 app.post('/api/upload/get-config', async (req, res) => { + // 获取客户端IP用于速率限制 + const clientIP = apiKeyLimiter.getClientKey(req); + const rateLimitKey = `api_key:${clientIP}`; + + // 检查是否被封锁 + if (apiKeyLimiter.isBlocked(rateLimitKey)) { + const result = apiKeyLimiter.recordFailure(rateLimitKey); + console.warn(`[安全] API密钥暴力枚举检测 - IP: ${clientIP}`); + return res.status(429).json({ + success: false, + message: `请求过于频繁,请在 ${result.waitMinutes} 分钟后重试`, + blocked: true + }); + } + try { const { api_key } = req.body; @@ -2317,12 +2479,18 @@ app.post('/api/upload/get-config', async (req, res) => { const user = db.prepare('SELECT * FROM users WHERE upload_api_key = ?').get(api_key); if (!user) { + // 记录失败尝试 + const result = apiKeyLimiter.recordFailure(rateLimitKey); + console.warn(`[安全] API密钥验证失败 - IP: ${clientIP}, 剩余尝试: ${result.remainingAttempts}`); return res.status(401).json({ success: false, message: 'API密钥无效或已过期' }); } + // 验证成功,清除失败记录 + apiKeyLimiter.recordSuccess(rateLimitKey); + if (user.is_banned) { return res.status(403).json({ success: false, @@ -2337,7 +2505,9 @@ app.post('/api/upload/get-config', async (req, res) => { }); } - // 返回SFTP配置 + // 返回SFTP配置(注意:密码通过此API返回给上传工具使用) + // 上传工具需要密码才能连接SFTP,这是设计上的需要 + // 安全措施:1. 速率限制防止暴力枚举 2. API密钥是32位随机字符串 res.json({ success: true, sftp_config: { @@ -2382,7 +2552,7 @@ app.post('/api/share/create', authMiddleware, (req, res) => { db.prepare('UPDATE shares SET storage_type = ? WHERE id = ?') .run(req.user.current_storage_type || 'sftp', result.id); - const shareUrl = `${getProtocol(req)}://${req.get('host')}/s/${result.share_code}`; + const shareUrl = `${getSecureBaseUrl(req)}/s/${result.share_code}`; res.json({ success: true, @@ -2410,7 +2580,7 @@ app.get('/api/share/my', authMiddleware, (req, res) => { success: true, shares: shares.map(share => ({ ...share, - share_url: `${getProtocol(req)}://${req.get('host')}/s/${share.share_code}` + share_url: `${getSecureBaseUrl(req)}/s/${share.share_code}` })) }); } catch (error) { @@ -2422,32 +2592,52 @@ app.get('/api/share/my', authMiddleware, (req, res) => { } }); -// 删除分享 +// 删除分享(增强IDOR防护) app.delete('/api/share/:id', authMiddleware, (req, res) => { try { - // 先获取分享信息以获得share_code - const share = ShareDB.findById(req.params.id); + const shareId = parseInt(req.params.id, 10); - if (share && share.user_id === req.user.id) { - // 删除缓存 - if (shareFileCache.has(share.share_code)) { - shareFileCache.delete(share.share_code); - console.log(`[缓存清除] 分享码: ${share.share_code}`); - } - - // 删除数据库记录 - ShareDB.delete(req.params.id, req.user.id); - - res.json({ - success: true, - message: '分享已删除' - }); - } else { - res.status(404).json({ + // 验证ID格式 + if (isNaN(shareId) || shareId <= 0) { + return res.status(400).json({ success: false, - message: '分享不存在或无权限' + message: '无效的分享ID' }); } + + // 先获取分享信息以获得share_code + const share = ShareDB.findById(shareId); + + if (!share) { + return res.status(404).json({ + success: false, + message: '分享不存在' + }); + } + + // 严格的权限检查 + if (share.user_id !== req.user.id) { + // 记录可疑的越权尝试 + console.warn(`[安全] IDOR尝试 - 用户 ${req.user.id}(${req.user.username}) 试图删除用户 ${share.user_id} 的分享 ${shareId}`); + return res.status(403).json({ + success: false, + message: '无权限删除此分享' + }); + } + + // 删除缓存 + if (shareFileCache.has(share.share_code)) { + shareFileCache.delete(share.share_code); + console.log(`[缓存清除] 分享码: ${share.share_code}`); + } + + // 删除数据库记录 + ShareDB.delete(shareId, req.user.id); + + res.json({ + success: true, + message: '分享已删除' + }); } catch (error) { console.error('删除分享失败:', error); res.status(500).json({ @@ -2857,20 +3047,20 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, // 验证密码(如果需要) if (share.share_password) { - // 记录密码错误 + if (!password || !ShareDB.verifyPassword(password, share.share_password)) { + // 只在密码错误时记录失败 if (req.shareRateLimitKey) { shareLimiter.recordFailure(req.shareRateLimitKey); } - if (!password || !ShareDB.verifyPassword(password, share.share_password)) { return res.status(401).json({ success: false, message: '密码错误或未提供密码' }); - // 清除失败记录(密码验证成功) - if (req.shareRateLimitKey && share.share_password) { - shareLimiter.recordSuccess(req.shareRateLimitKey); - } + } + // 密码验证成功,清除失败记录 + if (req.shareRateLimitKey) { + shareLimiter.recordSuccess(req.shareRateLimitKey); } } @@ -3076,14 +3266,16 @@ app.get('/api/admin/storage-stats', authMiddleware, adminMiddleware, async (req, let availableDisk = 0; try { - // 获取本地存储目录所在分区的磁盘信息 - const { stdout: dfOutput } = await execAsync(`df -B 1 / | tail -1`, { encoding: 'utf8' }); - const parts = dfOutput.trim().split(/\s+/); - + // 获取本地存储目录所在分区的磁盘信息(避免使用shell) + const { stdout: dfOutput } = await execFileAsync('df', ['-B', '1', localStorageDir], { encoding: 'utf8' }); + // 取最后一行数据 + const lines = dfOutput.trim().split('\n'); + const parts = lines[lines.length - 1].trim().split(/\s+/); + if (parts.length >= 4) { - totalDisk = parseInt(parts[1]) || 0; // 总大小 - usedDisk = parseInt(parts[2]) || 0; // 已使用 - availableDisk = parseInt(parts[3]) || 0; // 可用 + totalDisk = parseInt(parts[1], 10) || 0; // 总大小 + usedDisk = parseInt(parts[2], 10) || 0; // 已使用 + availableDisk = parseInt(parts[3], 10) || 0; // 可用 } } catch (dfError) { console.error('获取磁盘信息失败:', dfError.message); @@ -3091,11 +3283,13 @@ app.get('/api/admin/storage-stats', authMiddleware, adminMiddleware, async (req, try { // 获取本地存储目录所在的驱动器号 const driveLetter = localStorageDir.charAt(0); - if (!/^[A-Za-z]$/.test(driveLetter)) { + const normalizedDrive = driveLetter.toUpperCase(); + if (!/^[A-Z]$/.test(normalizedDrive)) { throw new Error('Invalid drive letter'); } - const { stdout: wmicOutput } = await execAsync( - `wmic logicaldisk where "DeviceID='${driveLetter}:'" get Size,FreeSpace /value`, + const { stdout: wmicOutput } = await execFileAsync( + 'wmic', + ['logicaldisk', 'where', `DeviceID='${normalizedDrive}:'`, 'get', 'Size,FreeSpace', '/value'], { encoding: 'utf8' } ); diff --git a/backend/storage.js b/backend/storage.js index 493ef6f..a9a5a84 100644 --- a/backend/storage.js +++ b/backend/storage.js @@ -231,27 +231,55 @@ class LocalStorageClient { /** * 获取完整路径(带安全检查) + * 增强的路径遍历防护 */ getFullPath(relativePath) { + // 0. 输入验证:检查空字节注入和其他危险字符 + if (typeof relativePath !== 'string') { + throw new Error('无效的路径类型'); + } + + // 检查空字节注入(%00, \x00) + if (relativePath.includes('\x00') || relativePath.includes('%00')) { + console.warn('[安全] 检测到空字节注入尝试:', relativePath); + throw new Error('路径包含非法字符'); + } + // 1. 规范化路径,移除 ../ 等危险路径 let normalized = path.normalize(relativePath || '').replace(/^(\.\.[\/\\])+/, ''); - // 2. ✅ 修复:将绝对路径转换为相对路径(解决Linux环境下的问题) + // 2. 额外检查:移除路径中间的 .. (防止 a/../../../etc/passwd 绕过) + // 解析后的路径不应包含 .. + if (normalized.includes('..')) { + console.warn('[安全] 检测到目录遍历尝试:', relativePath); + throw new Error('路径包含非法字符'); + } + + // 3. 将绝对路径转换为相对路径(解决Linux环境下的问题) if (path.isAbsolute(normalized)) { // 移除开头的 / 或 Windows 盘符,转为相对路径 normalized = normalized.replace(/^[\/\\]+/, '').replace(/^[a-zA-Z]:/, ''); } - // 3. 空字符串或 . 表示根目录 + // 4. 空字符串或 . 表示根目录 if (normalized === '' || normalized === '.') { return this.basePath; } - // 4. 拼接完整路径 + // 5. 拼接完整路径 const fullPath = path.join(this.basePath, normalized); - // 5. 安全检查:确保路径在用户目录内(防止目录遍历攻击) - if (!fullPath.startsWith(this.basePath)) { + // 6. 解析真实路径(处理符号链接)后再次验证 + const resolvedBasePath = path.resolve(this.basePath); + const resolvedFullPath = path.resolve(fullPath); + + // 7. 安全检查:确保路径在用户目录内(防止目录遍历攻击) + if (!resolvedFullPath.startsWith(resolvedBasePath)) { + console.warn('[安全] 检测到路径遍历攻击:', { + input: relativePath, + resolved: resolvedFullPath, + base: resolvedBasePath + }); throw new Error('非法路径访问'); }