fix(security): 修复多个严重安全漏洞和运行时崩溃

- devices.js: 修复 hasRoomField 重复 const 声明导致模块加载失败
- devices.js: 修复 batch-status 路由 affectedCount 未定义导致崩溃
- devices.js: 修复 sequelize.literal SQL 注入,转义 LIKE 通配符和字段名
- server.js: 修复 initializeApp() 未 await 导致服务器在初始化前接收请求
- backup.js: 添加 resolveBackupPath() 防止路径遍历攻击(6处)
- inventory.js: 用 bulkCreate 替代原始 SQL 拼接,消除 SQL 注入
- systemSettings.js: 添加全局 authMiddleware,修复敏感操作无认证
- dangerousOperations.js: 添加全局认证,修复日志清理功能完全不可用
- background.js: 添加文件名安全检查、扩展名白名单、路径校验
This commit is contained in:
zhang96110
2026-04-01 06:02:55 +00:00
parent fa26964cbb
commit 6eda3ae1f2
7 changed files with 143 additions and 61 deletions
+24 -3
View File
@@ -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) {
+58 -27
View File
@@ -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({
+14 -1
View File
@@ -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: '只有管理员才能清理日志' });
}
+22 -9
View File
@@ -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('所在机房');
}
+2 -17
View File
@@ -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({
+13
View File
@@ -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');
+10 -4
View File
@@ -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() 中调用