diff --git a/backend/routes/background.js b/backend/routes/background.js index 491f1be..e9b4cf6 100644 --- a/backend/routes/background.js +++ b/backend/routes/background.js @@ -58,8 +58,29 @@ router.post('/upload', (req, res) => { } const file = req.files.file; - const fileName = `${Date.now()}_${file.name}`; - const filePath = path.join(UPLOAD_DIR, fileName); + + // 安全检查:拒绝路径遍历字符 + if (file.name.includes('..') || file.name.includes('/') || file.name.includes('\\')) { + return res.status(400).json({ error: '文件名包含非法字符' }); + } + + // 白名单校验文件扩展名,只允许图片格式 + const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp']; + const ext = path.extname(file.name).toLowerCase(); + if (!allowedExtensions.includes(ext)) { + return res.status(400).json({ error: '只允许上传图片文件(jpg/png/gif/webp/svg/bmp)' }); + } + + // 使用随机文件名,仅保留安全扩展名 + const safeName = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}${ext}`; + const filePath = path.join(UPLOAD_DIR, safeName); + + // 二次校验:确保路径在 uploads 目录内 + const resolvedPath = path.resolve(filePath); + const resolvedUploadDir = path.resolve(UPLOAD_DIR); + if (!resolvedPath.startsWith(resolvedUploadDir + path.sep)) { + return res.status(400).json({ error: '文件路径不合法' }); + } file.mv(filePath, err => { if (err) { @@ -67,7 +88,7 @@ router.post('/upload', (req, res) => { return res.status(500).json({ error: '文件保存失败' }); } - const fileUrl = `/uploads/${fileName}`; + const fileUrl = `/uploads/${safeName}`; res.json({ path: fileUrl }); }); } catch (error) { diff --git a/backend/routes/backup.js b/backend/routes/backup.js index eca92e1..a8bf280 100644 --- a/backend/routes/backup.js +++ b/backend/routes/backup.js @@ -4,6 +4,34 @@ const fs = require('fs'); const path = require('path'); const zlib = require('zlib'); const { authMiddleware } = require('../middleware/auth'); + +/** + * 安全解析备份文件路径,防止路径遍历攻击 + * @param {string} filename - 用户提供的文件名 + * @param {string} backupPath - 备份目录的绝对路径 + * @returns {{ safePath: string, error: string|null }} + */ +function resolveBackupPath(filename, backupPath) { + if (!filename || typeof filename !== 'string') { + return { safePath: null, error: '请提供备份文件名' }; + } + // 拒绝包含路径分隔符或上级目录引用的文件名 + if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { + return { safePath: null, error: '文件名包含非法字符' }; + } + // 只允许备份文件扩展名 + if (!filename.endsWith('.json') && !filename.endsWith('.json.gz')) { + return { safePath: null, error: '文件名格式不正确' }; + } + const safePath = path.join(backupPath, filename); + // 二次校验:解析后的路径必须在备份目录内 + const resolvedPath = path.resolve(safePath); + const resolvedBackupDir = path.resolve(backupPath); + if (!resolvedPath.startsWith(resolvedBackupDir + path.sep)) { + return { safePath: null, error: '文件路径不合法' }; + } + return { safePath: resolvedPath, error: null }; +} const { getBackupPath, ensureBackupDir, @@ -150,7 +178,11 @@ router.get('/validate/:filename', async (req, res) => { try { const { filename } = req.params; const backupPath = getBackupPath(); - const filePath = path.join(backupPath, filename); + const { safePath, error } = resolveBackupPath(filename, backupPath); + if (error) { + return res.status(400).json({ success: false, message: error }); + } + const filePath = safePath; if (!fs.existsSync(filePath)) { return res.status(404).json({ @@ -179,15 +211,12 @@ router.get('/restore-progress/:filename', authMiddleware, async (req, res) => { const { filename } = req.params; const options = req.query.options ? JSON.parse(req.query.options) : {}; - if (!filename) { - return res.status(400).json({ - success: false, - message: '请提供备份文件名', - }); - } - const backupPath = getBackupPath(); - const filePath = path.join(backupPath, filename); + const { safePath, error } = resolveBackupPath(filename, backupPath); + if (error) { + return res.status(400).json({ success: false, message: error }); + } + const filePath = safePath; if (!fs.existsSync(filePath)) { return res.status(404).json({ @@ -296,15 +325,12 @@ router.post('/restore', async (req, res) => { try { const { filename, options = {} } = req.body; - if (!filename) { - return res.status(400).json({ - success: false, - message: '请提供备份文件名', - }); - } - const backupPath = getBackupPath(); - const filePath = path.join(backupPath, filename); + const { safePath, error } = resolveBackupPath(filename, backupPath); + if (error) { + return res.status(400).json({ success: false, message: error }); + } + const filePath = safePath; if (!fs.existsSync(filePath)) { return res.status(404).json({ @@ -411,7 +437,11 @@ router.get('/download/:filename', (req, res) => { try { const { filename } = req.params; const backupPath = getBackupPath(); - const filePath = path.join(backupPath, filename); + const { safePath, error } = resolveBackupPath(filename, backupPath); + if (error) { + return res.status(400).json({ success: false, message: error }); + } + const filePath = safePath; if (!fs.existsSync(filePath)) { return res.status(404).json({ @@ -439,7 +469,11 @@ router.delete('/:filename', (req, res) => { try { const { filename } = req.params; const backupPath = getBackupPath(); - const filePath = path.join(backupPath, filename); + const { safePath, error } = resolveBackupPath(filename, backupPath); + if (error) { + return res.status(400).json({ success: false, message: error }); + } + const filePath = safePath; if (!fs.existsSync(filePath)) { return res.status(404).json({ @@ -971,15 +1005,12 @@ router.post('/remote/upload', async (req, res) => { try { const { filename, targetIds } = req.body; - if (!filename) { - return res.status(400).json({ - success: false, - message: '请提供备份文件名', - }); - } - const backupPath = getBackupPath(); - const filePath = path.join(backupPath, filename); + const { safePath, error } = resolveBackupPath(filename, backupPath); + if (error) { + return res.status(400).json({ success: false, message: error }); + } + const filePath = safePath; if (!fs.existsSync(filePath)) { return res.status(404).json({ diff --git a/backend/routes/dangerousOperations.js b/backend/routes/dangerousOperations.js index e50d3ee..6098170 100644 --- a/backend/routes/dangerousOperations.js +++ b/backend/routes/dangerousOperations.js @@ -1,5 +1,6 @@ const express = require('express'); const router = express.Router(); +const { authMiddleware } = require('../middleware/auth'); const { logDangerousOperation, getDangerousOperationsLogs, @@ -9,6 +10,9 @@ const { calculateRiskLevel, } = require('../utils/dangerousOperationLogger'); +// 全局认证中间件 +router.use(authMiddleware); + router.post('/log', async (req, res) => { try { const { @@ -103,7 +107,16 @@ router.delete('/logs/clean', async (req, res) => { try { const { daysToKeep = 90 } = req.query; - if (!req.user || req.user.role !== 'admin') { + // 通过关联表查询用户角色,判断是否为管理员 + const UserRole = require('../models/UserRole'); + const Role = require('../models/Role'); + const userRole = await UserRole.findOne({ + where: { UserId: req.user.userId }, + include: [{ model: Role }], + }); + const isAdmin = userRole && userRole.Role && userRole.Role.roleCode === 'admin'; + + if (!isAdmin) { return res.status(403).json({ error: '只有管理员才能清理日志' }); } diff --git a/backend/routes/devices.js b/backend/routes/devices.js index 02d6c09..7f43089 100644 --- a/backend/routes/devices.js +++ b/backend/routes/devices.js @@ -532,7 +532,12 @@ router.get('/', validateQuery(queryDeviceSchema), async (req, res) => { console.log('数据库类型:', dbDialect); // 转义关键词中的特殊字符,防止SQL注入 - const escapedKeyword = keyword.replace(/'/g, "''"); + // 转义单引号(SQL注入)和 LIKE 通配符(%、_) + const escapedKeyword = keyword + .replace(/\\/g, '\\\\') + .replace(/'/g, "''") + .replace(/%/g, '\\%') + .replace(/_/g, '\\_'); // 基础字段搜索条件(只包含文本类型字段) const searchConditions = [ @@ -562,15 +567,20 @@ router.get('/', validateQuery(queryDeviceSchema), async (req, res) => { if (customFields.length > 0) { // 使用原始SQL查询JSON字段 const jsonConditions = customFields.map(field => { - const fieldName = field.fieldName; + // 转义 fieldName 中的特殊字符,防止通过字段名注入 + const safeFieldName = field.fieldName + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/'/g, "''"); + // 使用 sequelize.literal 构建原始SQL条件 if (dbDialect === 'mysql') { return sequelize.literal( - `JSON_EXTRACT(customFields, '$."${fieldName}"') LIKE '%${escapedKeyword}%'` + `JSON_EXTRACT(customFields, '$."${safeFieldName}"') LIKE '%${escapedKeyword}%' ESCAPE '\\\\'` ); } else { return sequelize.literal( - `json_extract(customFields, '$.${fieldName}') LIKE '%${escapedKeyword}%'` + `json_extract(customFields, '$.${safeFieldName}') LIKE '%${escapedKeyword}%' ESCAPE '\\'` ); } }); @@ -1485,8 +1495,10 @@ router.post('/import', async (req, res) => { }); for (const [rackId, powerToAdd] of rackPowerMap) { + // 确保功率值为合法数字,防止SQL注入 + const safePower = Number(powerToAdd) || 0; await Rack.update( - { currentPower: sequelize.literal(`currentPower + ${powerToAdd}`) }, + { currentPower: sequelize.literal(`currentPower + ${safePower}`) }, { where: { rackId }, transaction: t } ); } @@ -1809,8 +1821,9 @@ router.put('/batch-move', async (req, res) => { for (const [rackId, powerChange] of sourceRackPowerChanges) { if (rackId !== targetRackId) { + const safePower = Number(powerChange) || 0; await Rack.update( - { currentPower: sequelize.literal(`currentPower + ${powerChange}`) }, + { currentPower: sequelize.literal(`currentPower + ${safePower}`) }, { where: { rackId } } ); } @@ -1821,8 +1834,9 @@ router.put('/batch-move', async (req, res) => { } if (targetRackPowerChange.change !== 0) { + const safeTargetPower = Number(targetRackPowerChange.change) || 0; await Rack.update( - { currentPower: sequelize.literal(`currentPower + ${targetRackPowerChange.change}`) }, + { currentPower: sequelize.literal(`currentPower + ${safeTargetPower}`) }, { where: { rackId: targetRackId } } ); } @@ -1972,8 +1986,7 @@ router.get('/enhanced-export', async (req, res) => { const headerSet = new Set(); // 添加机房字段(如果存在) - const hasRoomField = allFields.some(f => f.fieldName === 'roomName'); - if (!hasRoomField) { + if (!allFields.some(f => f.fieldName === 'roomName')) { headerSet.add('所在机房'); } diff --git a/backend/routes/inventory.js b/backend/routes/inventory.js index f0b9607..6845cf1 100644 --- a/backend/routes/inventory.js +++ b/backend/routes/inventory.js @@ -250,23 +250,8 @@ router.post('/plans/:planId/start', async (req, res) => { } if (recordsToCreate.length > 0) { - const now = - dbDialect === 'mysql' - ? new Date().toISOString().replace('T', ' ').replace('Z', '') - : new Date().toISOString(); - const placeholders = recordsToCreate - .map( - r => - `('${r.recordId}', '${r.taskId}', '${r.planId}', '${r.deviceId}', '${r.deviceName}', '${r.deviceType}', '${r.serialNumber || ''}', '${r.rackId}', ${r.position}, 'pending', '${now}', '${now}')` - ) - .join(','); - - if (placeholders) { - await sequelize.query(` - INSERT INTO inventory_records (recordId, taskId, planId, deviceId, deviceName, deviceType, serialNumber, rackId, position, status, createdAt, updatedAt) - VALUES ${placeholders} - `); - } + // 使用 Sequelize bulkCreate 替代原始 SQL,自动参数化防止 SQL 注入 + await InventoryRecord.bulkCreate(recordsToCreate, { individualHooks: false }); } await plan.update({ diff --git a/backend/routes/systemSettings.js b/backend/routes/systemSettings.js index 0e6eecd..6c67987 100644 --- a/backend/routes/systemSettings.js +++ b/backend/routes/systemSettings.js @@ -3,6 +3,19 @@ const router = express.Router(); const fs = require('fs'); const path = require('path'); const { Op } = require('sequelize'); +const { authMiddleware } = require('../middleware/auth'); + +// 公开路由(无需认证)- 使用 originalUrl 匹配,兼容子路由挂载 +const publicRoutes = ['/system/info']; + +// 全局认证中间件:所有路由默认需要认证 +router.use((req, res, next) => { + const fullPath = req.originalUrl || req.path; + if (publicRoutes.some(p => fullPath.endsWith(p))) { + return next(); + } + return authMiddleware(req, res, next); +}); const SystemSetting = require('../models/SystemSetting'); const { FRONTEND } = require('../config'); diff --git a/backend/server.js b/backend/server.js index 6c61965..a15f770 100644 --- a/backend/server.js +++ b/backend/server.js @@ -226,7 +226,14 @@ const { specs, customCSS } = require('./swagger'); const { authMiddleware } = require('./middleware/auth'); const loadRoutes = require('./utils/routeLoader'); -initializeApp(); +initializeApp().then(() => { + app.listen(PORT, () => { + console.log(`服务器运行在 http://localhost:${PORT}`); + }); +}).catch(err => { + console.error('应用初始化失败:', err); + process.exit(1); +}); const PUBLIC_PATHS = [ '/auth', @@ -234,6 +241,7 @@ const PUBLIC_PATHS = [ '/docs', '/api-docs', '/api-docs.json', + '/system-settings/system/info', ]; const isPublicPath = (path) => { @@ -318,6 +326,4 @@ app.get('/health', async (req, res) => { res.status(statusCode).json(health); }); -app.listen(PORT, () => { - console.log(`服务器运行在 http://localhost:${PORT}`); -}); +// app.listen 已在 initializeApp().then() 中调用