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:
@@ -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
@@ -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({
|
||||
|
||||
@@ -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: '只有管理员才能清理日志' });
|
||||
}
|
||||
|
||||
|
||||
@@ -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('所在机房');
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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() 中调用
|
||||
|
||||
Reference in New Issue
Block a user