diff --git a/backend/server.js b/backend/server.js index 8d47d63..befc27a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -168,6 +168,27 @@ function safeDeleteFile(filePath) { } } + +// 验证请求路径是否在分享范围内(防止越权访问) +function isPathWithinShare(requestPath, share) { + if (!requestPath || !share) { + return false; + } + + // 规范化路径(移除 ../ 等危险路径,统一分隔符) + const normalizedRequest = path.normalize(requestPath).replace(/^(\.\.[\/\\])+/, '').replace(/\\/g, '/'); + const normalizedShare = path.normalize(share.share_path).replace(/\\/g, '/'); + + if (share.share_type === 'file') { + // 单文件分享:只允许下载该文件 + return normalizedRequest === normalizedShare; + } else { + // 目录分享:只允许下载该目录及其子目录下的文件 + // 确保分享路径以斜杠结尾用于前缀匹配 + const sharePrefix = normalizedShare.endsWith('/') ? normalizedShare : normalizedShare + '/'; + return normalizedRequest === normalizedShare || normalizedRequest.startsWith(sharePrefix); + } +} // 清理旧的临时文件(启动时执行一次) function cleanupOldTempFiles() { const uploadsDir = path.join(__dirname, 'uploads'); @@ -222,30 +243,6 @@ function formatFileSize(bytes) { return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; } - -// 生成分享URL(处理非标准端口) -function generateShareUrl(req, shareCode) { - const protocol = req.protocol; - const host = req.get('host'); // 可能包含或不包含端口 - - // 如果 host 已经包含端口号,直接使用 - if (host.includes(':')) { - return `${protocol}://${host}/s/${shareCode}`; - } - - // 如果没有端口号,检查是否需要添加 - // 从环境变量读取公开端口(nginx监听的端口) - const publicPort = process.env.PUBLIC_PORT || null; - - // 如果配置了公开端口,且不是标准端口(80/443),则添加端口号 - if (publicPort && publicPort !== '80' && publicPort !== '443') { - return `${protocol}://${host}:${publicPort}/s/${shareCode}`; - } - - // 标准端口或未配置,不添加端口号 - return `${protocol}://${host}/s/${shareCode}`; -} - // ===== 公开API ===== // 健康检查 @@ -1233,7 +1230,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 = generateShareUrl(req, result.share_code); + const shareUrl = `${req.protocol}://${req.get('host')}/s/${result.share_code}`; res.json({ success: true, @@ -1260,7 +1257,7 @@ app.get('/api/share/my', authMiddleware, (req, res) => { success: true, shares: shares.map(share => ({ ...share, - share_url: generateShareUrl(req, share.share_code) + share_url: `${req.protocol}://${req.get('host')}/s/${share.share_code}` })) }); } catch (error) { @@ -1370,6 +1367,8 @@ app.post('/api/share/:code/verify', async (req, res) => { responseData.file = shareFileCache.get(code); } else { // 缓存未命中,查询存储 + const storageType = shareOwner.current_storage_type || 'sftp'; + console.log(`[缓存未命中] 分享码: ${code},存储类型: ${storageType}`); try { // 获取分享者的用户信息 const shareOwner = UserDB.findById(share.user_id); @@ -1377,10 +1376,6 @@ app.post('/api/share/:code/verify', async (req, res) => { throw new Error('分享者不存在'); } - // 使用分享者当前的存储类型(而不是分享创建时的存储类型) - const storageType = shareOwner.current_storage_type || 'sftp'; - console.log(`[缓存未命中] 分享码: ${code},存储类型: ${storageType} (分享者当前)`); - // 使用统一存储接口 const { StorageInterface } = require('./storage'); const userForStorage = { @@ -1435,7 +1430,7 @@ app.post('/api/share/:code/verify', async (req, res) => { const httpBaseUrl = share.http_download_base_url || ''; const baseUrl = httpBaseUrl ? httpBaseUrl.replace(/\/+$/, '') : ''; const normalizedFilePath = filePath.startsWith('/') ? filePath : `/${filePath}`; - const storageType = shareOwner.current_storage_type || 'sftp'; + const storageType = share.storage_type || 'sftp'; const httpDownloadUrl = (storageType === 'sftp' && baseUrl) ? `${baseUrl}${normalizedFilePath}` : null; responseData.file = { @@ -1498,8 +1493,8 @@ app.post('/api/share/:code/list', async (req, res) => { // 使用统一存储接口,根据分享的storage_type选择存储后端 const { StorageInterface } = require('./storage'); - const storageType = shareOwner.current_storage_type || 'sftp'; - console.log(`[分享列表] 存储类型: ${storageType} (分享者当前), 分享路径: ${share.share_path}`); + const storageType = share.storage_type || 'sftp'; + console.log(`[分享列表] 存储类型: ${storageType}, 分享路径: ${share.share_path}`); // 临时构造用户对象以使用存储接口 const userForStorage = { @@ -1672,6 +1667,15 @@ app.get('/api/share/:code/download-file', async (req, res) => { } } + // ✅ 安全验证:检查请求路径是否在分享范围内(防止越权访问) + if (!isPathWithinShare(filePath, share)) { + console.warn(`[安全] 检测到越权访问尝试 - 分享码: ${code}, 请求路径: ${filePath}, 分享路径: ${share.share_path}`); + return res.status(403).json({ + success: false, + message: '无权访问该文件' + }); + } + // 获取分享者的用户信息 const shareOwner = UserDB.findById(share.user_id); if (!shareOwner) { @@ -1911,14 +1915,6 @@ app.post('/api/admin/users/:id/ban', authMiddleware, adminMiddleware, (req, res) const { id } = req.params; const { banned } = req.body; - // 防止管理员封禁自己 - if (parseInt(id) === req.user.id && banned) { - return res.status(400).json({ - success: false, - message: '不能封禁自己的账号' - }); - } - UserDB.setBanStatus(id, banned); res.json({ diff --git a/backend/storage.js b/backend/storage.js index 43a5ec8..ae993a5 100644 --- a/backend/storage.js +++ b/backend/storage.js @@ -195,12 +195,23 @@ class LocalStorageClient { */ getFullPath(relativePath) { // 1. 规范化路径,移除 ../ 等危险路径 - const normalized = path.normalize(relativePath).replace(/^(\.\.[\/\\])+/, ''); + let normalized = path.normalize(relativePath || '').replace(/^(\.\.[\/\])+/, ''); - // 2. 拼接完整路径 + // 2. ✅ 修复:将绝对路径转换为相对路径(解决Linux环境下的问题) + if (path.isAbsolute(normalized)) { + // 移除开头的 / 或 Windows 盘符,转为相对路径 + normalized = normalized.replace(/^[\/\]+/, '').replace(/^[a-zA-Z]:/, ''); + } + + // 3. 空字符串或 . 表示根目录 + if (normalized === '' || normalized === '.') { + return this.basePath; + } + + // 4. 拼接完整路径 const fullPath = path.join(this.basePath, normalized); - // 3. 安全检查:确保路径在用户目录内(防止目录遍历攻击) + // 5. 安全检查:确保路径在用户目录内(防止目录遍历攻击) if (!fullPath.startsWith(this.basePath)) { throw new Error('非法路径访问'); }