Files
MC_Report/backend/middleware/security.js
canglan 8c5ce78fd0 chore: add AGPLv3 copyright header to all source files
- 52 JS files (backend + public/js): header with
  Copyright (C) 2026 Sea Network Technology Studio
  Author: CangLan <admin@sea-studio.top>
  + AGPLv3 notice
- idempotent (skips if header present), all syntax-checked
2026-08-19 20:27:05 +08:00

195 lines
5.5 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.
/*
* MC Report System
* Copyright (C) 2026 Sea Network Technology Studio
* Author: CangLan <admin@sea-studio.top>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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;
let cachedApiKeyMtime = 0;
function getApiKey() {
try {
const stat = fs.statSync(CONFIG_PATH);
if (cachedApiKeyMtime === stat.mtimeMs) return cachedApiKey;
cachedApiKey = readApiKey();
cachedApiKeyMtime = stat.mtimeMs;
} catch {
cachedApiKey = null;
cachedApiKeyMtime = 0;
}
return cachedApiKey;
}
function clearApiKeyCache() { cachedApiKey = null; cachedApiKeyMtime = 0; }
const xssOptions = {
whiteList: {
p: [], br: [], strong: [], b: [], em: [], i: [], u: [], s: [], strike: [],
a: ['href', 'title', 'target', 'rel'], span: [], div: [],
ul: [], ol: [], li: [], h1: [], h2: [], h3: [], h4: [], h5: [], h6: [],
table: [], thead: [], tbody: [], tfoot: [], tr: [], th: [], td: [], caption: [],
code: [], pre: [], blockquote: [], hr: [], img: ['src', 'alt', 'title', 'width', 'height'],
font: ['color', 'size', 'face'], small: [], sub: [], sup: [],
},
stripIgnoreTag: true,
stripIgnoreTagBody: ['script', 'style', 'xml', 'iframe', 'object', 'embed'],
onTagAttr: (tag, name, value) => {
if ((name === 'href' || name === 'src') && /^\s*(javascript|data):/i.test(value)) return '';
return;
},
};
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,
skip: (req) => !!(req.headers.authorization),
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,
};