security: throttle sensitive admin routes (#111)

This commit is contained in:
Cita
2026-03-15 17:28:34 +08:00
committed by GitHub
parent 20480c4964
commit 67ad62c0d5
7 changed files with 280 additions and 13 deletions
+85
View File
@@ -0,0 +1,85 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
type RateLimitOptions = {
bucket: string;
max: number;
windowMs: number;
message?: string;
};
type RateLimitEntry = {
count: number;
resetAt: number;
};
const DEFAULT_MESSAGE = '请求过于频繁,请稍后再试';
const rateLimitStore = new Map<string, RateLimitEntry>();
function normalizeIp(rawIp: string | null | undefined): string {
const ip = (rawIp || '').trim();
if (!ip) return 'unknown';
if (ip.startsWith('::ffff:')) return ip.slice('::ffff:'.length).trim() || 'unknown';
if (ip === '::1') return '127.0.0.1';
return ip;
}
function extractClientIp(request: FastifyRequest): string {
const forwarded = request.headers['x-forwarded-for'];
if (Array.isArray(forwarded)) {
const first = forwarded.find((value) => typeof value === 'string' && value.trim().length > 0);
if (first) return normalizeIp(first.split(',')[0]);
}
if (typeof forwarded === 'string' && forwarded.trim().length > 0) {
return normalizeIp(forwarded.split(',')[0]);
}
return normalizeIp(request.ip);
}
function getRateLimitKey(bucket: string, request: FastifyRequest): string {
return `${bucket}:${extractClientIp(request)}`;
}
function pruneExpiredEntries(nowMs: number): void {
for (const [key, entry] of rateLimitStore.entries()) {
if (entry.resetAt > nowMs) continue;
rateLimitStore.delete(key);
}
}
export function resetRequestRateLimitStore(): void {
rateLimitStore.clear();
}
export function createRateLimitGuard(options: RateLimitOptions) {
const message = options.message || DEFAULT_MESSAGE;
return async function rateLimitGuard(request: FastifyRequest, reply: FastifyReply) {
const nowMs = Date.now();
pruneExpiredEntries(nowMs);
const key = getRateLimitKey(options.bucket, request);
const current = rateLimitStore.get(key);
if (!current || current.resetAt <= nowMs) {
rateLimitStore.set(key, {
count: 1,
resetAt: nowMs + options.windowMs,
});
return;
}
if (current.count >= options.max) {
const retryAfterSec = Math.max(1, Math.ceil((current.resetAt - nowMs) / 1000));
reply
.code(429)
.header('retry-after', String(retryAfterSec))
.send({ success: false, message });
return;
}
current.count += 1;
rateLimitStore.set(key, current);
};
}