refactor: modularize backend security and observability

This commit is contained in:
237899745
2026-07-27 13:06:54 +08:00
parent f90311e68c
commit dce1e3622a
25 changed files with 1633 additions and 849 deletions

View File

@@ -0,0 +1,68 @@
const crypto = require('crypto');
const { logger } = require('../utils/logger');
const CSRF_COOKIE_NAME = 'csrf_token';
const CSRF_COOKIE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
function generateCsrfToken() {
return crypto.randomBytes(32).toString('hex');
}
function setCsrfCookie(res, token, secureCookies) {
res.cookie(CSRF_COOKIE_NAME, token, {
httpOnly: false,
secure: secureCookies,
sameSite: secureCookies ? 'strict' : 'lax',
maxAge: CSRF_COOKIE_MAX_AGE_MS
});
}
function createCsrfCookieMiddleware({ secureCookies = false } = {}) {
return (req, res, next) => {
if (!req.cookies[CSRF_COOKIE_NAME]) {
setCsrfCookie(res, generateCsrfToken(), secureCookies);
}
next();
};
}
function tokensEqual(left, right) {
if (typeof left !== 'string' || typeof right !== 'string') return false;
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function csrfProtection(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const hasCookieAuth = Boolean(req.cookies?.token || req.cookies?.refreshToken);
if (!hasCookieAuth) return next();
const cookieToken = req.cookies[CSRF_COOKIE_NAME];
const headerToken = req.headers['x-csrf-token'];
if (!tokensEqual(cookieToken, headerToken)) {
(req.log || logger).warn({
path: req.path,
hasCookieToken: Boolean(cookieToken),
hasHeaderToken: Boolean(headerToken)
}, 'CSRF validation failed');
return res.status(403).json({
success: false,
message: 'CSRF 验证失败,请刷新页面后重试',
requestId: req.id
});
}
return next();
}
module.exports = {
CSRF_COOKIE_NAME,
createCsrfCookieMiddleware,
csrfProtection,
generateCsrfToken,
setCsrfCookie,
tokensEqual
};

View File

@@ -1,3 +1,5 @@
const { getRequestId, logger } = require('../utils/logger');
function expressErrorHandler(err, req, res, next) {
if (res.headersSent) return next(err);
@@ -6,14 +8,18 @@ function expressErrorHandler(err, req, res, next) {
? requestedStatus
: 500;
console.error(`[未处理错误] ${req.method} ${req.originalUrl}`, {
message: err?.message,
stack: err?.stack
});
const requestId = getRequestId(req);
(req.log || logger).error({
err,
method: req.method,
path: req.path || req.originalUrl,
statusCode: status
}, 'unhandled request error');
return res.status(status).json({
success: false,
message: status >= 500 ? '服务器内部错误' : (err?.message || '请求处理失败')
message: status >= 500 ? '服务器内部错误' : (err?.message || '请求处理失败'),
...(requestId ? { requestId } : {})
});
}

View File

@@ -0,0 +1,149 @@
const { rateLimit } = require('express-rate-limit');
const { logger } = require('../utils/logger');
function parsePositiveInteger(value, fallback) {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function createGlobalApiLimiter(options = {}) {
const windowMs = parsePositiveInteger(options.windowMs ?? process.env.API_RATE_LIMIT_WINDOW_MS, 60_000);
const limit = parsePositiveInteger(options.limit ?? process.env.API_RATE_LIMIT_MAX, 600);
return rateLimit({
windowMs,
limit,
standardHeaders: 'draft-8',
legacyHeaders: false,
skip: (req) => req.method === 'OPTIONS' || req.path === '/health',
handler: (req, res) => {
(req.log || logger).warn({ method: req.method, path: req.path }, 'global API rate limit exceeded');
res.status(429).json({
success: false,
message: '请求过于频繁,请稍后再试',
requestId: req.id
});
}
});
}
class RateLimiter {
constructor(options = {}) {
this.maxAttempts = options.maxAttempts || 5;
this.windowMs = options.windowMs || 15 * 60 * 1000;
this.blockDuration = options.blockDuration || 30 * 60 * 1000;
this.attempts = new Map();
this.blockedKeys = new Map();
this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60 * 1000);
this.cleanupInterval.unref?.();
}
getClientKey(req) {
return req.ip || req.socket?.remoteAddress || 'unknown';
}
isBlocked(key) {
const blockInfo = this.blockedKeys.get(key);
if (!blockInfo) return false;
if (Date.now() > blockInfo.expiresAt) {
this.blockedKeys.delete(key);
this.attempts.delete(key);
return false;
}
return true;
}
recordFailure(key) {
const now = Date.now();
if (this.isBlocked(key)) {
const blockInfo = this.blockedKeys.get(key);
return {
blocked: true,
remainingAttempts: 0,
resetTime: blockInfo.expiresAt,
waitMinutes: Math.ceil((blockInfo.expiresAt - now) / 60_000),
needCaptcha: true
};
}
let attemptInfo = this.attempts.get(key);
if (!attemptInfo || now > attemptInfo.windowEnd) {
attemptInfo = { count: 0, windowEnd: now + this.windowMs, firstAttempt: now };
}
attemptInfo.count += 1;
this.attempts.set(key, attemptInfo);
if (attemptInfo.count >= this.maxAttempts) {
const blockExpiresAt = now + this.blockDuration;
this.blockedKeys.set(key, { expiresAt: blockExpiresAt, blockedAt: now });
logger.warn({ key, attempts: attemptInfo.count, blockDuration: this.blockDuration }, 'failure limiter blocked key');
return {
blocked: true,
remainingAttempts: 0,
resetTime: blockExpiresAt,
waitMinutes: Math.ceil(this.blockDuration / 60_000),
needCaptcha: true
};
}
return {
blocked: false,
remainingAttempts: this.maxAttempts - attemptInfo.count,
resetTime: attemptInfo.windowEnd,
waitMinutes: 0,
needCaptcha: attemptInfo.count >= 2
};
}
getFailureCount(key) {
const attemptInfo = this.attempts.get(key);
if (!attemptInfo || Date.now() > attemptInfo.windowEnd) return 0;
return attemptInfo.count;
}
recordSuccess(key) {
this.attempts.delete(key);
this.blockedKeys.delete(key);
}
cleanup() {
const now = Date.now();
let cleanedAttempts = 0;
let cleanedBlocks = 0;
for (const [key, info] of this.attempts.entries()) {
if (now > info.windowEnd) {
this.attempts.delete(key);
cleanedAttempts += 1;
}
}
for (const [key, info] of this.blockedKeys.entries()) {
if (now > info.expiresAt) {
this.blockedKeys.delete(key);
cleanedBlocks += 1;
}
}
if (cleanedAttempts || cleanedBlocks) {
logger.info({ cleanedAttempts, cleanedBlocks }, 'failure limiter cleanup completed');
}
}
getStats() {
return {
activeAttempts: this.attempts.size,
blockedKeys: this.blockedKeys.size
};
}
destroy() {
if (this.cleanupInterval) clearInterval(this.cleanupInterval);
}
}
module.exports = {
RateLimiter,
createGlobalApiLimiter
};

View File

@@ -0,0 +1,49 @@
const LOCAL_HEALTH_ADDRESSES = new Set([
'127.0.0.1',
'::1',
'::ffff:127.0.0.1'
]);
function applySecurityHeaders(req, res, options = {}) {
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), geolocation=(), microphone=()');
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; media-src 'self' blob: https:; connect-src 'self' https: ws: wss:; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self';"
);
if (req?.secure || (!req && options.secureByDefault)) {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
res.removeHeader('X-Powered-By');
}
function securityHeadersMiddleware(options = {}) {
return (req, res, next) => {
applySecurityHeaders(req, res, options);
next();
};
}
function createHttpsEnforcementMiddleware({ enabled = false } = {}) {
return (req, res, next) => {
const remoteAddress = req.socket?.remoteAddress || '';
const isLocalHealthCheck = req.path === '/api/health' && LOCAL_HEALTH_ADDRESSES.has(remoteAddress);
if (!enabled || isLocalHealthCheck || req.secure) return next();
return res.status(400).json({
success: false,
message: '仅支持HTTPS访问请使用HTTPS',
requestId: req.id
});
};
}
module.exports = {
applySecurityHeaders,
createHttpsEnforcementMiddleware,
securityHeadersMiddleware
};