refactor(api): enhance donation creation with rate limiting, Zod validation, and secure logging
- Add rate limiting to prevent abuse on /api/donations/create endpoint - Replace manual input validation with Zod schema for better error handling and type safety - Switch from console.log to secureLogger to comply with LGPD and avoid exposing sensitive data - Remove sensitive request body logging in AbacatePay client for improved security
This commit is contained in:
1 parent
bb6b320d1f
commit
a1800227a7
11 files changed
+876
-138
No files matched your search
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Rate Limiter para ambiente serverless
|
||||
* Utiliza Map em memória com TTL (Time To Live)
|
||||
* Adequado para serverless de curta duração
|
||||
*/
|
||||
|
||||
interface RateLimitConfig {
|
||||
maxRequests: number;
|
||||
windowMs: number;
|
||||
}
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
// Cache em memória de requisições por IP
|
||||
const requestCache = new Map<string, RateLimitEntry>();
|
||||
|
||||
// Configuração de rate limits por endpoint
|
||||
const RATE_LIMITS: Record<string, RateLimitConfig> = {
|
||||
"/api/donations/create": {
|
||||
maxRequests: 5,
|
||||
windowMs: 10 * 60 * 1000, // 10 minutos
|
||||
},
|
||||
"/api/donations/status": {
|
||||
maxRequests: 30,
|
||||
windowMs: 60 * 1000, // 1 minuto
|
||||
},
|
||||
"/api/donations/verify": {
|
||||
maxRequests: 20,
|
||||
windowMs: 60 * 1000, // 1 minuto
|
||||
},
|
||||
"/api/webhooks/abacatepay": {
|
||||
maxRequests: 100,
|
||||
windowMs: 60 * 1000, // 1 minuto
|
||||
},
|
||||
};
|
||||
|
||||
// Limpar cache expirado a cada 5 minutos
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
let cleaned = 0;
|
||||
|
||||
for (const [key, entry] of requestCache.entries()) {
|
||||
if (now > entry.resetAt) {
|
||||
requestCache.delete(key);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleaned > 0) {
|
||||
console.log(`🧹 Rate limiter: ${cleaned} entradas expiradas removidas`);
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
/**
|
||||
* Verifica se uma requisição excede o rate limit
|
||||
* @param ip - Endereço IP do cliente
|
||||
* @param path - Caminho da requisição (ex: /api/donations/create)
|
||||
* @returns Objeto com informações sobre o rate limit
|
||||
*/
|
||||
export function checkRateLimit(
|
||||
ip: string,
|
||||
path: string
|
||||
): {
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
resetAt: number;
|
||||
limit: number;
|
||||
} {
|
||||
const config = RATE_LIMITS[path];
|
||||
|
||||
// Se não há config para este path, permite tudo
|
||||
if (!config) {
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: Infinity,
|
||||
resetAt: Date.now() + 60000,
|
||||
limit: Infinity,
|
||||
};
|
||||
}
|
||||
|
||||
const key = `${ip}:${path}`;
|
||||
const now = Date.now();
|
||||
const entry = requestCache.get(key);
|
||||
|
||||
// Se não existe entrada ou expirou, criar nova
|
||||
if (!entry || now > entry.resetAt) {
|
||||
const newEntry: RateLimitEntry = {
|
||||
count: 1,
|
||||
resetAt: now + config.windowMs,
|
||||
};
|
||||
requestCache.set(key, newEntry);
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: config.maxRequests - 1,
|
||||
resetAt: newEntry.resetAt,
|
||||
limit: config.maxRequests,
|
||||
};
|
||||
}
|
||||
|
||||
// Se existe e não expirou, incrementar contador
|
||||
entry.count++;
|
||||
|
||||
if (entry.count > config.maxRequests) {
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: entry.resetAt,
|
||||
limit: config.maxRequests,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: config.maxRequests - entry.count,
|
||||
resetAt: entry.resetAt,
|
||||
limit: config.maxRequests,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrai o IP real de uma requisição
|
||||
* Considera headers de proxies/CDN (x-forwarded-for, x-real-ip)
|
||||
*/
|
||||
export function getClientIP(request: Request): string {
|
||||
// Tentar x-forwarded-for primeiro (pode ter múltiplos IPs)
|
||||
const forwardedFor = request.headers.get("x-forwarded-for");
|
||||
if (forwardedFor) {
|
||||
// Pegar apenas o primeiro IP (cliente original)
|
||||
return forwardedFor.split(",")[0].trim();
|
||||
}
|
||||
|
||||
// Tentar x-real-ip
|
||||
const realIP = request.headers.get("x-real-ip");
|
||||
if (realIP) {
|
||||
return realIP.trim();
|
||||
}
|
||||
|
||||
// Fallback: usar conexão remota
|
||||
return request.headers.get("cf-connecting-ip") || "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware helper para criar resposta de rate limit
|
||||
*/
|
||||
export function createRateLimitResponse(resetAt: number): Response {
|
||||
const retryAfter = Math.ceil((resetAt - Date.now()) / 1000);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Rate limit excedido",
|
||||
message: "Muitas requisições. Tente novamente mais tarde.",
|
||||
retryAfter,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Retry-After": retryAfter.toString(),
|
||||
"X-RateLimit-Limit": "1",
|
||||
"X-RateLimit-Remaining": "0",
|
||||
"X-RateLimit-Reset": new Date(resetAt).toISOString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user