diff --git a/backend/server.js b/backend/server.js index 1beda9d..2b7c265 100644 --- a/backend/server.js +++ b/backend/server.js @@ -24,6 +24,8 @@ const { generateToken, authMiddleware, adminMiddleware } = require('./auth'); const app = express(); 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'; // 在反向代理(如 Nginx/Cloudflare)后部署时,信任代理以正确识别协议/IP/HTTPS app.set('trust proxy', process.env.TRUST_PROXY || true); @@ -76,6 +78,19 @@ app.use(cors(corsOptions)); app.use(express.json()); app.use(cookieParser()); +// 强制HTTPS(可通过环境变量控制,默认关闭以兼容本地环境) +app.use((req, res, next) => { + if (!ENFORCE_HTTPS) return next(); + const proto = req.get('x-forwarded-proto') || (req.secure ? 'https' : 'http'); + if (proto !== 'https') { + return res.status(400).json({ + success: false, + message: '仅支持HTTPS访问,请使用HTTPS' + }); + } + return next(); +}); + // Session配置(用于验证码) const isSecureCookie = process.env.COOKIE_SECURE === 'true'; const sameSiteMode = isSecureCookie ? 'none' : 'lax'; // HTTPS下允许跨域获取验证码 @@ -126,6 +141,62 @@ function sanitizeInput(str) { }); } +// HTML转义(用于模板输出) +function escapeHtml(str) { + if (typeof str !== 'string') return str; + return str.replace(/[&<>"']/g, char => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[char])); +} + +// 规范化并校验HTTP直链前缀,只允许http/https +function sanitizeHttpBaseUrl(raw) { + if (!raw) return null; + try { + const url = new URL(raw); + if (!['http:', 'https:'].includes(url.protocol)) { + return null; + } + url.search = ''; + url.hash = ''; + // 去掉多余的结尾斜杠,保持路径稳定 + url.pathname = url.pathname.replace(/\/+$/, ''); + return url.toString(); + } catch { + return null; + } +} + +// 构建安全的下载URL,编码路径片段并拒绝非HTTP(S)前缀 +function buildHttpDownloadUrl(rawBaseUrl, filePath) { + const baseUrl = sanitizeHttpBaseUrl(rawBaseUrl); + if (!baseUrl || !filePath) return null; + + try { + const url = new URL(baseUrl); + const normalizedPath = filePath.startsWith('/') ? filePath : `/${filePath}`; + const safeSegments = normalizedPath + .split('/') + .filter(Boolean) + .map(segment => encodeURIComponent(segment)); + const safePath = safeSegments.length ? '/' + safeSegments.join('/') : ''; + + const basePath = url.pathname.replace(/\/+$/, ''); + const joinedPath = `${basePath}${safePath || '/'}`; + url.pathname = joinedPath || '/'; + url.search = ''; + url.hash = ''; + return url.toString(); + } catch (err) { + console.warn('[安全] 生成下载URL失败:', err.message); + return null; + } +} + // 应用XSS过滤到所有POST/PUT请求的body app.use((req, res, next) => { if ((req.method === 'POST' || req.method === 'PUT') && req.body) { @@ -830,7 +901,9 @@ app.get('/api/captcha', captchaRateLimitMiddleware, (req, res) => { // 用户注册(简化版) app.post('/api/register', [ - body('username').isLength({ min: 3, max: 20 }).withMessage('用户名长度3-20个字符'), + body('username') + .isLength({ min: 3, max: 20 }).withMessage('用户名长度3-20个字符') + .matches(USERNAME_REGEX).withMessage('用户名仅允许中英文、数字、下划线、点和短横线'), body('email').isEmail().withMessage('邮箱格式不正确'), body('password').isLength({ min: 6 }).withMessage('密码至少6个字符') ], @@ -874,6 +947,7 @@ app.post('/api/register', const verifyToken = generateRandomToken(24); const expiresAtMs = Date.now() + 30 * 60 * 1000; // 30分钟 + const safeUsernameForMail = escapeHtml(username); // 创建用户(不需要FTP配置),标记未验证 const userId = UserDB.create({ @@ -891,7 +965,7 @@ app.post('/api/register', await sendMail( email, '邮箱验证 - 玩玩云', - `

您好,${username}:

+ `

您好,${safeUsernameForMail}:

请点击下面的链接验证您的邮箱,30分钟内有效:

${verifyLink}

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

` @@ -923,7 +997,10 @@ app.post('/api/register', // 重新发送邮箱验证邮件 app.post('/api/resend-verification', [ body('email').optional({ checkFalsy: true }).isEmail().withMessage('邮箱格式不正确'), - body('username').optional({ checkFalsy: true }).isLength({ min: 3 }).withMessage('用户名格式不正确') + body('username') + .optional({ checkFalsy: true }) + .isLength({ min: 3 }).withMessage('用户名格式不正确') + .matches(USERNAME_REGEX).withMessage('用户名仅允许中英文、数字、下划线、点和短横线') ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { @@ -953,10 +1030,11 @@ app.post('/api/resend-verification', [ VerificationDB.setVerification(user.id, verifyToken, expiresAtMs); const verifyLink = `${getProtocol(req)}://${req.get('host')}/app.html?verifyToken=${verifyToken}`; + const safeUsernameForMail = escapeHtml(user.username); await sendMail( user.email, '邮箱验证 - 玩玩云', - `

您好,${user.username}:

+ `

您好,${safeUsernameForMail}:

请点击下面的链接验证您的邮箱,30分钟内有效:

${verifyLink}

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

` @@ -1008,9 +1086,14 @@ app.post('/api/password/forgot', [ } const user = UserDB.findByEmail(email); - // 为防止枚举账号,统一返回成功 if (!user) { - return res.json({ success: true, message: '如果邮箱存在,将收到重置邮件' }); + 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: '邮箱未验证,无法重置密码' }); } const token = generateRandomToken(24); @@ -1018,16 +1101,17 @@ app.post('/api/password/forgot', [ 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, '密码重置 - 玩玩云', - `

您好,${user.username}:

+ `

您好,${safeUsernameForMail}:

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

${resetLink}

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

` ); - res.json({ success: true, message: '如果邮箱存在,将收到重置邮件' }); + res.json({ success: true, message: '重置邮件已发送,请查收邮箱完成验证' }); } catch (error) { const status = error.status || 500; console.error('发送密码重置邮件失败:', error); @@ -1052,6 +1136,17 @@ app.post('/api/password/reset', [ return res.status(400).json({ success: false, message: '无效或已过期的重置链接' }); } + const user = UserDB.findById(tokenRow.user_id); + if (!user) { + return res.status(404).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: '邮箱未验证,无法重置密码' }); + } + // 更新密码 const hashed = require('bcryptjs').hashSync(new_password, 10); db.prepare('UPDATE users SET password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?') @@ -1248,6 +1343,7 @@ app.post('/api/login', app.get('/api/user/profile', authMiddleware, (req, res) => { // 不返回密码明文 const { ftp_password, password, ...safeUser } = req.user; + safeUser.http_download_base_url = sanitizeHttpBaseUrl(safeUser.http_download_base_url); res.json({ success: true, user: safeUser @@ -1260,7 +1356,11 @@ app.post('/api/user/update-ftp', [ body('ftp_host').notEmpty().withMessage('FTP主机不能为空'), body('ftp_port').isInt({ min: 1, max: 65535 }).withMessage('FTP端口范围1-65535'), - body('ftp_user').notEmpty().withMessage('FTP用户名不能为空') + body('ftp_user').notEmpty().withMessage('FTP用户名不能为空'), + body('http_download_base_url') + .optional({ checkFalsy: true }) + .isURL({ protocols: ['http', 'https'], require_protocol: true, require_tld: false }) + .withMessage('HTTP直链地址必须以 http/https 开头') ], async (req, res) => { const errors = validationResult(req); @@ -1282,6 +1382,14 @@ app.post('/api/user/update-ftp', http_download_base_url }); + const safeHttpBaseUrl = sanitizeHttpBaseUrl(http_download_base_url); + if (http_download_base_url && !safeHttpBaseUrl) { + return res.status(400).json({ + success: false, + message: 'HTTP直链地址必须是合法的http/https地址,且不能包含查询或片段' + }); + } + // 如果用户已配置FTP且密码为空,使用现有密码 let actualPassword = ftp_password; if (!ftp_password && req.user.has_ftp_config && req.user.ftp_password) { @@ -1313,7 +1421,7 @@ app.post('/api/user/update-ftp', ftp_port: safePort, ftp_user, ftp_password: actualPassword, - http_download_base_url: http_download_base_url || null, + http_download_base_url: safeHttpBaseUrl || null, has_ftp_config: 1 }); @@ -1336,7 +1444,9 @@ app.post('/api/admin/update-profile', authMiddleware, adminMiddleware, [ - body('username').isLength({ min: 3, max: 20 }).withMessage('用户名长度3-20个字符') + body('username') + .isLength({ min: 3, max: 20 }).withMessage('用户名长度3-20个字符') + .matches(USERNAME_REGEX).withMessage('用户名仅允许中英文、数字、下划线、点和短横线') ], async (req, res) => { const errors = validationResult(req); @@ -1453,7 +1563,9 @@ app.post('/api/user/change-password', app.post('/api/user/update-username', authMiddleware, [ - body('username').isLength({ min: 3 }).withMessage('用户名至少3个字符') + body('username') + .isLength({ min: 3 }).withMessage('用户名至少3个字符') + .matches(USERNAME_REGEX).withMessage('用户名仅允许中英文、数字、下划线、点和短横线') ], (req, res) => { const errors = validationResult(req); @@ -1565,25 +1677,14 @@ app.get('/api/files', authMiddleware, async (req, res) => { const list = await storage.list(dirPath); - const httpBaseUrl = req.user.http_download_base_url || ''; const storageType = req.user.current_storage_type || 'sftp'; + const sanitizedHttpBase = sanitizeHttpBaseUrl(req.user.http_download_base_url); const formattedList = list.map(item => { // 构建完整的文件路径用于下载 - let httpDownloadUrl = null; - // 只有SFTP存储且配置了HTTP下载地址时才提供HTTP下载URL - if (storageType === 'sftp' && httpBaseUrl && item.type !== 'd') { - // 移除基础URL末尾的斜杠(如果有) - const baseUrl = httpBaseUrl.replace(/\/+$/, ''); - - // 构建完整路径:当前目录路径 + 文件名 - const fullPath = dirPath === '/' - ? `/${item.name}` - : `${dirPath}/${item.name}`; - - // 拼接最终的下载URL - httpDownloadUrl = `${baseUrl}${fullPath}`; - } + const httpDownloadUrl = (storageType === 'sftp' && sanitizedHttpBase && item.type !== 'd') + ? buildHttpDownloadUrl(sanitizedHttpBase, dirPath === '/' ? `/${item.name}` : `${dirPath}/${item.name}`) + : null; return { name: item.name, @@ -2393,6 +2494,8 @@ app.post('/api/share/:code/verify', shareRateLimitMiddleware, async (req, res) = // 增加查看次数 ShareDB.incrementViewCount(code); + const sanitizedShareHttpBase = sanitizeHttpBaseUrl(share.http_download_base_url); + // 构建返回数据 const responseData = { success: true, @@ -2447,12 +2550,11 @@ app.post('/api/share/:code/verify', shareRateLimitMiddleware, async (req, res) = } if (fileInfo) { - // 移除基础URL末尾的斜杠 - const httpBaseUrl = share.http_download_base_url || ''; - const baseUrl = httpBaseUrl ? httpBaseUrl.replace(/\/+$/, '') : ''; const normalizedFilePath = filePath.startsWith('/') ? filePath : `/${filePath}`; // SFTP存储才提供HTTP下载URL,本地存储使用API下载 - const httpDownloadUrl = (storageType === 'sftp' && baseUrl) ? `${baseUrl}${normalizedFilePath}` : null; + const httpDownloadUrl = (storageType === 'sftp') + ? buildHttpDownloadUrl(sanitizedShareHttpBase, normalizedFilePath) + : null; const fileData = { name: fileName, @@ -2478,11 +2580,11 @@ app.post('/api/share/:code/verify', shareRateLimitMiddleware, async (req, res) = throw storageError; } // 存储失败时仍返回基本信息,只是没有大小 - const httpBaseUrl = share.http_download_base_url || ''; - const baseUrl = httpBaseUrl ? httpBaseUrl.replace(/\/+$/, '') : ''; const normalizedFilePath = filePath.startsWith('/') ? filePath : `/${filePath}`; const storageType = share.storage_type || 'sftp'; - const httpDownloadUrl = (storageType === 'sftp' && baseUrl) ? `${baseUrl}${normalizedFilePath}` : null; + const httpDownloadUrl = (storageType === 'sftp') + ? buildHttpDownloadUrl(sanitizedShareHttpBase, normalizedFilePath) + : null; responseData.file = { name: fileName, @@ -2584,7 +2686,7 @@ app.post('/api/share/:code/list', shareRateLimitMiddleware, async (req, res) => const storageInterface = new StorageInterface(userForStorage); storage = await storageInterface.connect(); - const httpBaseUrl = share.http_download_base_url || ''; + const sanitizedShareHttpBase = sanitizeHttpBaseUrl(share.http_download_base_url); let formattedList = []; // 如果是单文件分享 @@ -2604,14 +2706,13 @@ app.post('/api/share/:code/list', shareRateLimitMiddleware, async (req, res) => const fileInfo = list.find(item => item.name === fileName); if (fileInfo) { - // 移除基础URL末尾的斜杠 - const baseUrl = httpBaseUrl ? httpBaseUrl.replace(/\/+$/, '') : ''; - // 确保文件路径以斜杠开头 const normalizedFilePath = filePath.startsWith('/') ? filePath : `/${filePath}`; // SFTP存储才提供HTTP下载URL,本地存储使用API下载 - const httpDownloadUrl = (storageType === 'sftp' && baseUrl) ? `${baseUrl}${normalizedFilePath}` : null; + const httpDownloadUrl = (storageType === 'sftp') + ? buildHttpDownloadUrl(sanitizedShareHttpBase, normalizedFilePath) + : null; formattedList = [{ name: fileInfo.name, @@ -2632,21 +2733,10 @@ app.post('/api/share/:code/list', shareRateLimitMiddleware, async (req, res) => formattedList = list.map(item => { // 构建完整的文件路径用于下载 let httpDownloadUrl = null; - // SFTP存储才提供HTTP下载URL,本地存储使用API下载 - if (storageType === 'sftp' && httpBaseUrl && item.type !== 'd') { - // 移除基础URL末尾的斜杠 - const baseUrl = httpBaseUrl.replace(/\/+$/, ''); - - // 确保fullPath以斜杠开头 + if (storageType === 'sftp' && sanitizedShareHttpBase && item.type !== 'd') { const normalizedPath = fullPath.startsWith('/') ? fullPath : `/${fullPath}`; - - // 构建完整路径:当前目录路径 + 文件名 - const filePath = normalizedPath === '/' - ? `/${item.name}` - : `${normalizedPath}/${item.name}`; - - // 拼接最终的下载URL - httpDownloadUrl = `${baseUrl}${filePath}`; + const filePath = normalizedPath === '/' ? `/${item.name}` : `${normalizedPath}/${item.name}`; + httpDownloadUrl = buildHttpDownloadUrl(sanitizedShareHttpBase, filePath); } return {