Files
MC_Report/backend/middleware/security.js

155 lines
3.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const xss = require('xss');
const rateLimit = require('express-rate-limit');
const fs = require('fs');
const path = require('path');
const CONFIG_PATH = path.join(__dirname, '..', '..', 'data', 'config.json');
function readApiKey() {
try {
if (fs.existsSync(CONFIG_PATH)) {
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')).api_key || null;
}
} catch {}
return null;
}
let cachedApiKey = null;
function getApiKey() {
if (cachedApiKey === null) cachedApiKey = readApiKey();
return cachedApiKey;
}
function clearApiKeyCache() { cachedApiKey = null; }
const xssOptions = {
whiteList: {},
stripIgnoreTag: true,
stripIgnoreTagBody: ['script', 'style', 'xml', 'iframe', 'object', 'embed'],
};
function sanitize(value) {
if (typeof value !== 'string') return value;
return xss(value, xssOptions).trim();
}
function sanitizeBody(req, res, next) {
function walk(obj) {
if (!obj || typeof obj !== 'object') return;
for (const key of Object.keys(obj)) {
if (typeof obj[key] === 'string') obj[key] = sanitize(obj[key]);
else if (typeof obj[key] === 'object') walk(obj[key]);
}
}
walk(req.body);
next();
}
function validateLengths(rules) {
return (req, res, next) => {
for (const [field, maxLen] of Object.entries(rules)) {
const val = req.body[field];
if (val && typeof val === 'string' && val.length > maxLen) {
return res.status(400).json({ error: `${field} 长度不能超过 ${maxLen} 个字符` });
}
}
next();
};
}
const loginLimiter = rateLimit({
windowMs: 60 * 1000,
max: 8,
message: { error: '登录尝试过于频繁请1分钟后再试' },
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip,
});
const registerLimiter = rateLimit({
windowMs: 60 * 1000,
max: 3,
message: { error: '注册请求过于频繁请1分钟后再试' },
standardHeaders: true,
legacyHeaders: false,
});
const ticketLimiter = rateLimit({
windowMs: 60 * 1000,
max: 500,
skip: (req) => !!(req.headers.authorization),
message: { error: '提交过于频繁,请稍后再试' },
standardHeaders: true,
legacyHeaders: false,
});
const ticketAnonLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
message: { error: '匿名提交过于频繁,请登录后再试或稍后重试' },
standardHeaders: true,
legacyHeaders: false,
});
const uploadLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
message: { error: '上传请求过于频繁' },
standardHeaders: true,
legacyHeaders: false,
});
const generalLimiter = rateLimit({
windowMs: 60 * 1000,
max: 300,
message: { error: '请求过于频繁' },
standardHeaders: true,
legacyHeaders: false,
});
const captchaLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
message: { error: '验证码请求过于频繁' },
});
function apiKeyGuard(req, res, next) {
const p = req.originalUrl;
if (p === '/api/health' || p.startsWith('/api/install')) return next();
const key = getApiKey();
if (!key) return next();
if (!req.headers['x-api-key'] || req.headers['x-api-key'].length !== key.length) return res.status(401).json({ error: '无效的 API 密钥' });
if (!timingSafeEqual(req.headers['x-api-key'], key)) return res.status(401).json({ error: '无效的 API 密钥' });
next();
}
function timingSafeEqual(a, b) {
let diff = a.length ^ b.length;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
function methodGuard(allowed) {
return (req, res, next) => {
if (!allowed.includes(req.method)) {
return res.status(405).json({ error: `不允许 ${req.method} 方法` });
}
next();
};
}
module.exports = {
sanitize,
sanitizeBody,
validateLengths,
loginLimiter,
registerLimiter,
ticketLimiter,
ticketAnonLimiter,
uploadLimiter,
generalLimiter,
captchaLimiter,
apiKeyGuard,
methodGuard,
clearApiKeyCache,
};