diff --git a/backend/server.js b/backend/server.js index c6ce666..a41dd01 100644 --- a/backend/server.js +++ b/backend/server.js @@ -74,6 +74,15 @@ const USERNAME_REGEX = /^[A-Za-z0-9_.\u4e00-\u9fa5-]{3,20}$/u; // 允许中英 const ENFORCE_HTTPS = process.env.ENFORCE_HTTPS === 'true'; const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB const DEFAULT_OSS_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB +const SHARE_CODE_REGEX = /^[A-Za-z0-9]{6,32}$/; +const COOKIE_SECURE_MODE = String(process.env.COOKIE_SECURE || '').toLowerCase(); +const SHOULD_USE_SECURE_COOKIES = + COOKIE_SECURE_MODE === 'true' || + (process.env.NODE_ENV === 'production' && COOKIE_SECURE_MODE !== 'false'); + +if (process.env.NODE_ENV === 'production' && COOKIE_SECURE_MODE !== 'true') { + console.warn('[安全警告] 生产环境建议设置 COOKIE_SECURE=true,以避免会话Cookie在HTTP下传输'); +} // ===== 安全配置:公开域名白名单(防止 Host Header 注入) ===== // 必须配置 PUBLIC_BASE_URL 环境变量,用于生成邮件链接和分享链接 @@ -139,22 +148,35 @@ app.set('trust proxy', trustProxyValue); console.log(`[安全] trust proxy 配置: ${JSON.stringify(trustProxyValue)}`); // 配置CORS - 严格白名单模式 -const allowedOrigins = process.env.ALLOWED_ORIGINS - ? process.env.ALLOWED_ORIGINS.split(',').map(origin => origin.trim()) - : []; // 默认为空数组,不允许任何域名 +const rawAllowedOrigins = process.env.ALLOWED_ORIGINS + ? process.env.ALLOWED_ORIGINS.split(',').map(origin => origin.trim()).filter(Boolean) + : []; +const wildcardOriginConfigured = rawAllowedOrigins.includes('*'); +const allowAllOriginsForDev = wildcardOriginConfigured && process.env.NODE_ENV !== 'production'; +const allowedOrigins = rawAllowedOrigins.filter(origin => origin !== '*'); + +if (wildcardOriginConfigured && process.env.NODE_ENV === 'production') { + console.error('❌ 错误: 生产环境禁止 ALLOWED_ORIGINS=*。请改为明确的域名白名单。'); +} const corsOptions = { credentials: true, origin: (origin, callback) => { + // 生产环境禁止通配符(credentials=true 时会导致任意来源携带Cookie) + if (wildcardOriginConfigured && process.env.NODE_ENV === 'production') { + callback(new Error('生产环境不允许 CORS 通配符配置')); + return; + } + // 生产环境必须配置白名单 - if (allowedOrigins.length === 0 && process.env.NODE_ENV === 'production') { + if (allowedOrigins.length === 0 && !allowAllOriginsForDev && process.env.NODE_ENV === 'production') { console.error('❌ 错误: 生产环境必须配置 ALLOWED_ORIGINS 环境变量!'); callback(new Error('CORS未配置')); return; } // 开发环境如果没有配置,允许 localhost - if (allowedOrigins.length === 0) { + if (allowedOrigins.length === 0 && !allowAllOriginsForDev) { const devOrigins = ['http://localhost:3000', 'http://127.0.0.1:3000']; if (!origin || devOrigins.some(o => origin.startsWith(o))) { callback(null, true); @@ -162,32 +184,52 @@ const corsOptions = { } } - // 严格白名单模式:只允许白名单中的域名 - // 但需要允许没有Origin头的同源请求(浏览器访问时不会发送Origin) + // 允许没有Origin头的同源请求和服务器请求 if (!origin) { - // 没有Origin头的请求通常是: - // 1. 浏览器的同源请求(不触发CORS) - // 2. 直接的服务器请求 - // 这些都应该允许 callback(null, true); - } else if (allowedOrigins.includes('*') || allowedOrigins.includes(origin)) { - // 白名单中的域名,或通配符允许所有域名 - callback(null, true); - } else { - // 拒绝不在白名单中的跨域请求 - console.warn(`[CORS] 拒绝来自未授权来源的请求: ${origin}`); - callback(new Error('CORS策略不允许来自该来源的访问')); + return; } + + if (allowedOrigins.includes(origin) || allowAllOriginsForDev) { + callback(null, true); + return; + } + + // 拒绝不在白名单中的跨域请求 + console.warn(`[CORS] 拒绝来自未授权来源的请求: ${origin}`); + callback(new Error('CORS策略不允许来自该来源的访问')); } }; +function applySecurityHeaders(req, res) { + // 防止点击劫持 + res.setHeader('X-Frame-Options', 'SAMEORIGIN'); + // 防止MIME类型嗅探 + res.setHeader('X-Content-Type-Options', 'nosniff'); + // XSS保护 + res.setHeader('X-XSS-Protection', '1; mode=block'); + // HTTPS严格传输安全(仅在可信的 HTTPS 连接时设置) + // req.secure 基于 trust proxy 配置,不会被不可信代理伪造 + if ((req && req.secure) || (!req && (ENFORCE_HTTPS || SHOULD_USE_SECURE_COOKIES))) { + res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + // 内容安全策略 + res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https: ws: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'self';"); + // 隐藏X-Powered-By + res.removeHeader('X-Powered-By'); +} + // 中间件 app.use(cors(corsOptions)); // 静态文件服务 - 提供前端页面 const frontendPath = path.join(__dirname, '../frontend'); console.log('[静态文件] 前端目录:', frontendPath); -app.use(express.static(frontendPath)); +app.use(express.static(frontendPath, { + setHeaders: (res) => { + applySecurityHeaders(null, res); + } +})); app.use(express.json({ limit: '10mb' })); // 限制请求体大小防止DoS app.use(cookieParser()); @@ -209,7 +251,7 @@ app.use((req, res, next) => { // 如果没有 CSRF cookie,则生成一个 if (!req.cookies[CSRF_COOKIE_NAME]) { const csrfToken = generateCsrfToken(); - const isSecureEnv = process.env.COOKIE_SECURE === 'true'; + const isSecureEnv = SHOULD_USE_SECURE_COOKIES; res.cookie(CSRF_COOKIE_NAME, csrfToken, { httpOnly: false, // 前端需要读取此值 secure: isSecureEnv, @@ -282,7 +324,7 @@ app.use((req, res, next) => { }); // Session配置(用于验证码) -const isSecureCookie = process.env.COOKIE_SECURE === 'true'; +const isSecureCookie = SHOULD_USE_SECURE_COOKIES; const sameSiteMode = isSecureCookie ? 'none' : 'lax'; // HTTPS下允许跨域获取验证码 // 安全检查:Session密钥配置 @@ -321,21 +363,7 @@ app.use(session({ // 安全响应头中间件 app.use((req, res, next) => { - // 防止点击劫持 - res.setHeader('X-Frame-Options', 'SAMEORIGIN'); - // 防止MIME类型嗅探 - res.setHeader('X-Content-Type-Options', 'nosniff'); - // XSS保护 - res.setHeader('X-XSS-Protection', '1; mode=block'); - // HTTPS严格传输安全(仅在可信的 HTTPS 连接时设置) - // req.secure 基于 trust proxy 配置,不会被不可信代理伪造 - if (req.secure) { - res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); - } - // 内容安全策略 - res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';"); - // 隐藏X-Powered-By - res.removeHeader('X-Powered-By'); + applySecurityHeaders(req, res); next(); }); @@ -1153,6 +1181,14 @@ function loginRateLimitMiddleware(req, res, next) { function shareRateLimitMiddleware(req, res, next) { const clientIP = shareLimiter.getClientKey(req); const { code } = req.params; + + if (!isValidShareCode(code)) { + return res.status(400).json({ + success: false, + message: '无效的分享码' + }); + } + const key = `share:${code}:${clientIP}`; // 检查是否被封锁 @@ -1173,6 +1209,7 @@ function shareRateLimitMiddleware(req, res, next) { + // ===== 工具函数 ===== /** @@ -1214,25 +1251,68 @@ function safeDeleteFile(filePath) { } +// 规范化虚拟文件路径(统一用于分享路径校验) +function normalizeVirtualPath(rawPath) { + if (typeof rawPath !== 'string') { + return null; + } + + let decoded = rawPath; + try { + decoded = decodeURIComponent(rawPath); + } catch { + // 忽略解码失败,使用原始输入继续校验 + } + + if (decoded.includes('\x00') || decoded.includes('%00')) { + return null; + } + + const unifiedPath = decoded.replace(/\\/g, '/'); + + // 严格拦截路径遍历片段(在 normalize 前先检查) + if (/(^|\/)\.\.(\/|$)/.test(unifiedPath)) { + return null; + } + + let normalized = path.posix.normalize(unifiedPath); + if (normalized === '' || normalized === '.') { + normalized = '/'; + } + + if (!normalized.startsWith('/')) { + normalized = `/${normalized}`; + } + + normalized = normalized.replace(/\/+$/g, ''); + return normalized || '/'; +} + +function isValidShareCode(code) { + return typeof code === 'string' && SHARE_CODE_REGEX.test(code); +} + // 验证请求路径是否在分享范围内(防止越权访问) 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, '/'); + const normalizedRequest = normalizeVirtualPath(requestPath); + const normalizedShare = normalizeVirtualPath(share.share_path); + + if (!normalizedRequest || !normalizedShare) { + return false; + } if (share.share_type === 'file') { // 单文件分享:只允许下载该文件 return normalizedRequest === normalizedShare; - } else { - // 目录分享:只允许下载该目录及其子目录下的文件 - // 确保分享路径以斜杠结尾用于前缀匹配 - const sharePrefix = normalizedShare.endsWith('/') ? normalizedShare : normalizedShare + '/'; - return normalizedRequest === normalizedShare || normalizedRequest.startsWith(sharePrefix); } + + // 目录分享:只允许下载该目录及其子目录下的文件 + const sharePrefix = normalizedShare.endsWith('/') ? normalizedShare : `${normalizedShare}/`; + return normalizedRequest === normalizedShare || normalizedRequest.startsWith(sharePrefix); } // 清理旧的临时文件(启动时执行一次) function cleanupOldTempFiles() { @@ -1456,7 +1536,7 @@ app.get('/api/csrf-token', (req, res) => { // 如果没有 token,生成一个新的 if (!csrfToken) { csrfToken = generateCsrfToken(); - const isSecureEnv = process.env.COOKIE_SECURE === 'true'; + const isSecureEnv = SHOULD_USE_SECURE_COOKIES; res.cookie(CSRF_COOKIE_NAME, csrfToken, { httpOnly: false, secure: isSecureEnv, @@ -1984,7 +2064,7 @@ app.post('/api/login', } // 增强Cookie安全设置 - const isSecureEnv = process.env.COOKIE_SECURE === 'true'; + const isSecureEnv = SHOULD_USE_SECURE_COOKIES; const cookieOptions = { httpOnly: true, secure: isSecureEnv, @@ -2062,7 +2142,7 @@ app.post('/api/refresh-token', (req, res) => { } // 更新Cookie中的token - const isSecureEnv = process.env.COOKIE_SECURE === 'true'; + const isSecureEnv = SHOULD_USE_SECURE_COOKIES; res.cookie('token', result.token, { httpOnly: true, secure: isSecureEnv, @@ -3520,7 +3600,7 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => { const command = new GetObjectCommand({ Bucket: bucket, Key: objectKey, - ResponseContentDisposition: `attachment; filename="${encodeURIComponent(filePath.split('/').pop())}"` + ResponseContentDisposition: `attachment; filename="${encodeURIComponent(normalizedPath.split('/').pop())}"` }); // 生成签名 URL(1小时有效) @@ -3535,7 +3615,7 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => { console.error('[OSS签名] 生成下载签名失败:', error); res.status(500).json({ success: false, - message: '生成下载签名失败: ' + error.message + message: getSafeErrorMessage(error, '生成下载签名失败,请稍后重试', '生成下载签名') }); } }); @@ -3764,7 +3844,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, '下载文件失败,请稍后重试', '下载文件') }); } // 发生错误时关闭存储连接 @@ -4095,8 +4175,8 @@ app.post('/api/share/create', authMiddleware, (req, res) => { } } - // 路径安全验证:防止路径遍历攻击 - if (file_path.includes('..') || file_path.includes('\x00')) { + const normalizedSharePath = normalizeVirtualPath(file_path); + if (!normalizedSharePath) { return res.status(400).json({ success: false, message: '路径包含非法字符' @@ -4108,12 +4188,12 @@ app.post('/api/share/create', authMiddleware, (req, res) => { category: 'share', action: 'create_share', message: '创建分享请求', - details: { share_type: actualShareType, file_path, file_name, expiry_days } + details: { share_type: actualShareType, file_path: normalizedSharePath, file_name, expiry_days } }); const result = ShareDB.create(req.user.id, { share_type: actualShareType, - file_path: file_path || '', + file_path: normalizedSharePath, file_name: file_name || '', password: normalizedPassword || null, expiry_days: expiry_days || null @@ -4127,8 +4207,8 @@ app.post('/api/share/create', authMiddleware, (req, res) => { // 记录分享创建日志 logShare(req, 'create_share', - `用户创建分享: ${actualShareType === 'file' ? '文件' : '目录'} ${file_path}`, - { shareCode: result.share_code, sharePath: file_path, shareType: actualShareType, hasPassword: !!normalizedPassword } + `用户创建分享: ${actualShareType === 'file' ? '文件' : '目录'} ${normalizedSharePath}`, + { shareCode: result.share_code, sharePath: normalizedSharePath, shareType: actualShareType, hasPassword: !!normalizedPassword } ); res.json({ @@ -4250,9 +4330,17 @@ app.get('/api/public/theme', (req, res) => { app.get('/api/share/:code/theme', (req, res) => { try { const { code } = req.params; - const share = ShareDB.findByCode(code); const globalTheme = SettingsDB.get('global_theme') || 'dark'; + if (!isValidShareCode(code)) { + return res.json({ + success: true, + theme: globalTheme + }); + } + + const share = ShareDB.findByCode(code); + if (!share) { return res.json({ success: true, @@ -4413,7 +4501,7 @@ app.post('/api/share/:code/verify', shareRateLimitMiddleware, async (req, res) = console.error('验证分享失败:', error); res.status(500).json({ success: false, - message: '验证失败: ' + error.message + message: getSafeErrorMessage(error, '验证失败,请稍后重试', '分享验证') }); } finally { if (storage) await storage.end(); @@ -4480,11 +4568,26 @@ app.post('/api/share/:code/list', shareRateLimitMiddleware, async (req, res) => } // 构造安全的请求路径,防止越权遍历 - const baseSharePath = (share.share_path || '/').replace(/\\/g, '/'); - const requestedPath = subPath - ? path.posix.normalize(`${baseSharePath}/${subPath}`) + const baseSharePath = normalizeVirtualPath(share.share_path || '/'); + if (!baseSharePath) { + return res.status(400).json({ + success: false, + message: '分享路径非法' + }); + } + + const rawSubPath = typeof subPath === 'string' ? subPath : ''; + const requestedPath = rawSubPath + ? normalizeVirtualPath(`${baseSharePath}/${rawSubPath}`) : baseSharePath; + if (!requestedPath) { + return res.status(400).json({ + success: false, + message: '请求路径非法' + }); + } + // 校验请求路径是否在分享范围内 if (!isPathWithinShare(requestedPath, share)) { return res.status(403).json({ @@ -4568,7 +4671,7 @@ app.post('/api/share/:code/list', shareRateLimitMiddleware, async (req, res) => console.error('获取分享文件列表失败:', error); res.status(500).json({ success: false, - message: '获取文件列表失败: ' + error.message + message: getSafeErrorMessage(error, '获取文件列表失败,请稍后重试', '分享列表') }); } finally { if (storage) await storage.end(); @@ -4626,14 +4729,8 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, }); } - if (!filePath || typeof filePath !== 'string') { - return res.status(400).json({ - success: false, - message: '缺少文件路径参数' - }); - } - - if (filePath.includes('\x00')) { + const normalizedFilePath = normalizeVirtualPath(filePath); + if (!normalizedFilePath) { return res.status(400).json({ success: false, message: '文件路径非法' @@ -4669,7 +4766,7 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, } // 安全验证:检查请求路径是否在分享范围内 - if (!isPathWithinShare(filePath, share)) { + if (!isPathWithinShare(normalizedFilePath, share)) { return res.status(403).json({ success: false, message: '无权访问该文件' @@ -4689,13 +4786,13 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, // 本地存储模式:返回后端下载 URL(短期 token,避免在 URL 中传密码) if (storageType !== 'oss') { - let downloadUrl = `${getSecureBaseUrl(req)}/api/share/${code}/download-file?path=${encodeURIComponent(filePath)}`; + let downloadUrl = `${getSecureBaseUrl(req)}/api/share/${code}/download-file?path=${encodeURIComponent(normalizedFilePath)}`; if (share.share_password) { const downloadToken = signEphemeralToken({ type: 'share_download', code, - path: filePath + path: normalizedFilePath }, 15 * 60); downloadUrl += `&token=${encodeURIComponent(downloadToken)}`; } @@ -4720,13 +4817,13 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); const { client, bucket, ossClient } = createS3ClientContextForUser(shareOwner); - const objectKey = ossClient.getObjectKey(filePath); + const objectKey = ossClient.getObjectKey(normalizedFilePath); // 创建 GetObject 命令 const command = new GetObjectCommand({ Bucket: bucket, Key: objectKey, - ResponseContentDisposition: `attachment; filename="${encodeURIComponent(filePath.split('/').pop())}"` + ResponseContentDisposition: `attachment; filename="${encodeURIComponent(normalizedFilePath.split('/').pop())}"` }); // 生成签名 URL(1小时有效) @@ -4743,7 +4840,7 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, console.error('[分享签名] 生成下载签名失败:', error); res.status(500).json({ success: false, - message: '生成下载签名失败: ' + error.message + message: getSafeErrorMessage(error, '生成下载签名失败,请稍后重试', '生成下载签名') }); } }); @@ -4752,7 +4849,9 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, // 注意:OSS 模式请使用 /api/share/:code/download-url 获取直连下载链接 app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, res) => { const { code } = req.params; - const { path: filePath, password, token } = req.query; + const rawFilePath = typeof req.query?.path === 'string' ? req.query.path : ''; + const { password, token } = req.query; + const filePath = normalizeVirtualPath(rawFilePath); let storage; let storageEnded = false; // 防止重复关闭 @@ -4769,14 +4868,6 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, }; if (!filePath) { - return res.status(400).json({ - success: false, - message: '缺少文件路径参数' - }); - } - - // 路径安全验证:防止目录遍历攻击 - if (filePath.includes('\x00')) { return res.status(400).json({ success: false, message: '文件路径非法' @@ -4884,7 +4975,7 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, if (!res.headersSent) { res.status(500).json({ success: false, - message: '文件下载失败: ' + error.message + message: getSafeErrorMessage(error, '下载文件失败,请稍后重试', '分享下载') }); } // 发生错误时关闭存储连接 @@ -4904,7 +4995,7 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, if (!res.headersSent) { res.status(500).json({ success: false, - message: '下载文件失败: ' + error.message + message: getSafeErrorMessage(error, '下载文件失败,请稍后重试', '分享下载') }); } // 如果发生错误,关闭存储连接 @@ -5272,7 +5363,7 @@ app.get('/api/admin/health-check', authMiddleware, adminMiddleware, async (req, // 3. HTTPS/Cookie安全配置 const enforceHttps = process.env.ENFORCE_HTTPS === 'true'; - const cookieSecure = process.env.COOKIE_SECURE === 'true'; + const cookieSecure = SHOULD_USE_SECURE_COOKIES; const httpsConfigured = enforceHttps && cookieSecure; checks.push({ name: 'HTTPS安全配置', @@ -6453,6 +6544,9 @@ app.delete('/api/admin/shares/:id', // 分享页面访问路由 app.get("/s/:code", (req, res) => { const shareCode = req.params.code; + if (!isValidShareCode(shareCode)) { + return res.status(404).send('分享链接不存在'); + } // 使用相对路径重定向,浏览器会自动使用当前的协议和host const frontendUrl = `/share.html?code=${shareCode}`; console.log(`[分享] 重定向到: ${frontendUrl}`);