refactor: modularize backend security and observability
This commit is contained in:
68
backend/middleware/csrf.js
Normal file
68
backend/middleware/csrf.js
Normal 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
|
||||
};
|
||||
@@ -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 } : {})
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
149
backend/middleware/rate-limit.js
Normal file
149
backend/middleware/rate-limit.js
Normal 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
|
||||
};
|
||||
49
backend/middleware/security.js
Normal file
49
backend/middleware/security.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user