fix: HIGH+MEDIUM bugs from full audit - listen error, JWT, upload, mailer, webhook

This commit is contained in:
2026-07-13 03:49:02 +08:00
parent 7c03fe7635
commit 532efb962f
8 changed files with 56 additions and 29 deletions

View File

@@ -42,17 +42,28 @@ function renderTemplate(template, vars) {
return { subject, body }; return { subject, body };
} }
let _transporter = null;
let _transporterKey = '';
function getTransporter(cfg) {
const key = `${cfg.host}:${cfg.port}:${cfg.user}`;
if (_transporter && _transporterKey === key) return _transporter;
_transporterKey = key;
_transporter = nodemailer.createTransport({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: { user: cfg.user, pass: cfg.pass } });
return _transporter;
}
async function sendEmail(to, templateCode, vars) { async function sendEmail(to, templateCode, vars) {
try { try {
const cfg = await getSmtpConfig(); const cfg = await getSmtpConfig();
if (!cfg.host || !cfg.user) { console.log('[Mailer] SMTP未配置'); return false; } if (!cfg.host || !cfg.user) { console.log('[Mailer] SMTP未配置'); return false; }
const template = await getTemplate(templateCode); const template = await getTemplate(templateCode);
if (!template) { console.log('[Mailer] 模板不存在:', templateCode); return false; } if (!template) { console.log('[Mailer] 模板不存在:', templateCode); return false; }
const allVars = { ...vars, site_name: await getSiteName(), site_url: await getSiteUrl() }; const siteName = await getSiteName();
const allVars = { ...vars, site_name: siteName, site_url: await getSiteUrl() };
const { subject, body } = renderTemplate(template, allVars); const { subject, body } = renderTemplate(template, allVars);
const transporter = getTransporter(cfg);
const transporter = nodemailer.createTransport({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: { user: cfg.user, pass: cfg.pass } }); await transporter.sendMail({ from: `"${siteName}" <${cfg.from}>`, to, subject, html: body });
await transporter.sendMail({ from: `"${await getSiteName()}" <${cfg.from}>`, to, subject, html: body });
return true; return true;
} catch (err) { console.error('[Mailer]', err.message); return false; } } catch (err) { console.error('[Mailer]', err.message); return false; }
} }

View File

@@ -4,8 +4,13 @@ const { getConfig } = require('../db');
function getSecret() { function getSecret() {
try { try {
const cfg = getConfig(); const cfg = getConfig();
return cfg?.jwt_secret || process.env.JWT_SECRET || 'mc-dev-secret'; const secret = cfg?.jwt_secret || process.env.JWT_SECRET;
} catch { return process.env.JWT_SECRET || 'mc-dev-secret'; } if (secret) return secret;
throw new Error('JWT_SECRET not configured');
} catch (e) {
if (e.message === 'JWT_SECRET not configured') throw e;
return process.env.JWT_SECRET || (() => { throw new Error('JWT_SECRET not configured'); })();
}
} }
function generateToken(user) { function generateToken(user) {

View File

@@ -33,13 +33,14 @@ function sanitize(value) {
} }
function sanitizeBody(req, res, next) { function sanitizeBody(req, res, next) {
if (req.body) { function walk(obj) {
for (const key of Object.keys(req.body)) { if (!obj || typeof obj !== 'object') return;
if (typeof req.body[key] === 'string') { for (const key of Object.keys(obj)) {
req.body[key] = sanitize(req.body[key]); if (typeof obj[key] === 'string') obj[key] = sanitize(obj[key]);
} else if (typeof obj[key] === 'object') walk(obj[key]);
} }
} }
walk(req.body);
next(); next();
} }

View File

@@ -24,7 +24,7 @@ function validateMagicBytes(buffer, mime) {
'image/png': () => head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4E && head[3] === 0x47, 'image/png': () => head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4E && head[3] === 0x47,
'image/gif': () => head.toString('ascii', 0, 6) === 'GIF89a' || head.toString('ascii', 0, 6) === 'GIF87a', 'image/gif': () => head.toString('ascii', 0, 6) === 'GIF89a' || head.toString('ascii', 0, 6) === 'GIF87a',
'image/webp': () => head.toString('ascii', 0, 4) === 'RIFF' && head.toString('ascii', 8, 12) === 'WEBP', 'image/webp': () => head.toString('ascii', 0, 4) === 'RIFF' && head.toString('ascii', 8, 12) === 'WEBP',
'video/mp4': () => buffer.includes(Buffer.from('ftyp')), 'video/mp4': () => buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70,
'video/webm': () => head[0] === 0x1A && head[1] === 0x45 && head[2] === 0xDF && head[3] === 0xA3, 'video/webm': () => head[0] === 0x1A && head[1] === 0x45 && head[2] === 0xDF && head[3] === 0xA3,
}; };
if (!sigs[mime]) return false; if (!sigs[mime]) return false;
@@ -58,18 +58,22 @@ const upload = multer({
function finalizeUpload(req, res, next) { function finalizeUpload(req, res, next) {
if (!req.files || req.files.length === 0) return next(); if (!req.files || req.files.length === 0) return next();
const filePaths = [];
try {
for (const file of req.files) { for (const file of req.files) {
const buf = fs.readFileSync(file.path); const fd = fs.openSync(file.path, 'r');
if (!ALLOWED_MIME[file.mimetype]) { const buf = Buffer.alloc(256);
fs.unlinkSync(file.path); fs.readSync(fd, buf, 0, 256, 0);
return res.status(400).json({ error: `不支持的文件类型: ${file.mimetype}` }); fs.closeSync(fd);
} filePaths.push(file.path);
if (!validateMagicBytes(buf, file.mimetype)) { if (!ALLOWED_MIME[file.mimetype]) throw new Error(`不支持的文件类型: ${file.mimetype}`);
fs.unlinkSync(file.path); if (!validateMagicBytes(buf, file.mimetype)) throw new Error('文件内容与声明类型不符,可能是恶意文件');
return res.status(400).json({ error: '文件内容与声明类型不符,可能是恶意文件' });
}
} }
next(); next();
} catch (e) {
for (const fp of filePaths) { try { fs.unlinkSync(fp); } catch {} }
return res.status(400).json({ error: e.message || '文件校验失败' });
}
} }
module.exports = { upload, finalizeUpload, validateMagicBytes, UPLOAD_DIR, ALLOWED_MIME }; module.exports = { upload, finalizeUpload, validateMagicBytes, UPLOAD_DIR, ALLOWED_MIME };

View File

@@ -1,7 +1,7 @@
const express = require('express'); const express = require('express');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const { v4: uuid } = require('uuid'); const { v4: uuid } = require('uuid');
const { query, getRow, getConfig } = require('../db'); const { query, getRow, getConfig, getPool } = require('../db');
const { generateToken, authenticate } = require('../middleware/auth'); const { generateToken, authenticate } = require('../middleware/auth');
const { sendEmail } = require('../mailer'); const { sendEmail } = require('../mailer');
@@ -47,7 +47,7 @@ router.post('/auth/login', async (req, res) => {
const { username, password } = req.body; const { username, password } = req.body;
if (!username || !password) return res.status(400).json({ error: '请输入用户名和密码' }); if (!username || !password) return res.status(400).json({ error: '请输入用户名和密码' });
const user = await getRow('SELECT * FROM users WHERE username = ?', [username]); const user = await getRow('SELECT * FROM users WHERE username = ?', [username]);
if (!user || !bcrypt.compareSync(password, user.password)) return res.status(401).json({ error: '用户名或密码错误' }); if (!user || !(await bcrypt.compare(password, user.password))) return res.status(401).json({ error: '用户名或密码错误' });
if (!user.active) return res.status(403).json({ error: '账号未激活' }); if (!user.active) return res.status(403).json({ error: '账号未激活' });
const token = generateToken(user); const token = generateToken(user);
res.json({ token, user: { id:user.id, username:user.username, game_name:user.game_name, game_uid:user.game_uid, role:user.role } }); res.json({ token, user: { id:user.id, username:user.username, game_name:user.game_name, game_uid:user.game_uid, role:user.role } });

View File

@@ -185,6 +185,7 @@ router.put('/:id', authenticate, requireRole('owner','admin'), async (req, res)
const fields = {}; const fields = {};
if (req.body.status) fields.status = req.body.status; if (req.body.status) fields.status = req.body.status;
if (req.body.priority) fields.priority = req.body.priority; if (req.body.priority) fields.priority = req.body.priority;
if (req.body.priority && !['low','medium','high','urgent'].includes(req.body.priority)) return res.status(400).json({ error: '无效的优先级' });
if (req.body.claim_note !== undefined) fields.claim_note = req.body.claim_note; if (req.body.claim_note !== undefined) fields.claim_note = req.body.claim_note;
if (!Object.keys(fields).length) return res.status(400).json({ error: '无更新内容' }); if (!Object.keys(fields).length) return res.status(400).json({ error: '无更新内容' });
const sets = Object.keys(fields).map(k => `${k} = ?`).join(', '); const sets = Object.keys(fields).map(k => `${k} = ?`).join(', ');

View File

@@ -125,7 +125,7 @@ app.use((err, req, res, next) => {
res.status(500).json({ error: '服务器内部错误' }); res.status(500).json({ error: '服务器内部错误' });
}); });
app.listen(PORT, () => {
if (isInstalled()) console.log(`Server running at http://localhost:${PORT}`); if (isInstalled()) console.log(`Server running at http://localhost:${PORT}`);
else console.log(`System not installed. Visit http://localhost:${PORT}/#/install`); else console.log(`System not installed. Visit http://localhost:${PORT}/#/install`);
}).on('error', (err) => { console.error('Failed to start:', err.message); process.exit(1); });
app.listen(PORT);

View File

@@ -9,7 +9,12 @@ async function sendWebhook(event, data) {
const events = cfg.events || 'all'; const events = cfg.events || 'all';
if (events !== 'all' && !events.split(',').map(s=>s.trim()).includes(event)) continue; if (events !== 'all' && !events.split(',').map(s=>s.trim()).includes(event)) continue;
const embed = buildEmbed(event, data); const embed = buildEmbed(event, data);
try { await fetch(cfg.webhook_url, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(embed) }); } catch {} try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 10000);
await fetch(cfg.webhook_url, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(embed), signal: ctrl.signal });
clearTimeout(t);
} catch (e) { console.error('[Webhook]', cfg.name, e.message); }
} }
} catch {} } catch {}
} }