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:
Evandro Fonseca Junior committed 2025-10-26 23:01:19 -03:00
1 parent bb6b320d1f
commit a1800227a7
11 files changed
+876 -138

No files matched your search

+6 -21
View File
@@ -57,7 +57,8 @@ class AbacatePayClient {
requestBody.customer = params.customer;
}
console.log("🔍 PIX Request:", JSON.stringify(requestBody, null, 2));
// Request body logging removido para segurança LGPD
// Dados sensíveis não devem aparecer em logs
const response = await fetch(`${this.config.baseUrl}/pixQrCode/create`, {
method: "POST",
@@ -68,16 +69,13 @@ class AbacatePayClient {
body: JSON.stringify(requestBody),
});
console.log("🔍 PIX Response Status:", response.status);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error("🔍 PIX Error:", errorData);
console.error("PIX Error:", errorData);
throw new Error(`Failed to create PIX: ${JSON.stringify(errorData)}`);
}
const result = await response.json();
console.log("🔍 PIX Success:", result);
if (result.error) {
throw new Error(result.error);
@@ -177,20 +175,10 @@ const apiKey = isProduction
? process.env.ABACATE_APIKEY || "demo_key"
: process.env.ABACATE_TEST_APIKEY || "demo_key";
// Log completo das variáveis de ambiente configuradas
console.log("\n🔧 ==========================================");
console.log("🔧 VARIÁVEIS DE AMBIENTE DO ABACATEPAY");
console.log("🔧 ==========================================");
console.log(`📊 NODE_ENV: ${process.env.NODE_ENV || "não definido"}`);
// Log das variáveis de ambiente (sem expor valores sensíveis)
console.log("\n🔧 Configuração do AbacatePay:");
console.log(`📊 Environment: ${isProduction ? "PRODUCTION" : "TEST"}`);
console.log(
`📊 API Key: ${apiKey ? "✅ Configurada (masqueada)" : "❌ NÃO CONFIGURADA"}`
);
console.log(
`📊 API Key preview: ${
apiKey ? apiKey.substring(0, 10) + "..." : "não definida"
}`
);
console.log(`📊 API Key: ${apiKey ? "✅ Configurada" : "❌ NÃO CONFIGURADA"}`);
console.log(
`📊 Webhook Secret: ${
process.env.ABACATEPAY_WEBHOOK_SECRET
@@ -198,9 +186,6 @@ console.log(
: "❌ NÃO CONFIGURADA"
}`
);
console.log(
`📊 Base URL: ${process.env.NEXT_PUBLIC_BASE_URL || "não definido"}`
);
console.log("🔧 ==========================================\n");
export const abacatePay = new AbacatePayClient({
+169
View File
@@ -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(),
},
}
);
}
+208
View File
@@ -0,0 +1,208 @@
/**
* Logger Seguro - Conformidade LGPD
* Mascara automaticamente dados pessoais sensíveis em logs
* Previne vazamento de informações em ambientes de produção
*/
interface MaskOptions {
/**
* Mascara parcial de email (mantém parte visível)
* Ex: [email protected] -> u***@domain.com
*/
maskEmail?: boolean;
/**
* Mascara parcial de CPF/CNPJ
* Ex: 123.456.789-00 -> ***.***.***-00
*/
maskDocument?: boolean;
/**
* Mascara telefone completo
* Ex: (11) 98765-4321 -> (11) *****-4321
*/
maskPhone?: boolean;
/**
* Remove completamente dados sensíveis em produção
*/
removeSensitive?: boolean;
}
const isProduction = process.env.NODE_ENV === "production";
/**
* Mascara email mantendo primeiro caractere e domínio
*/
function maskEmail(email: string): string {
if (!email || !email.includes("@")) return email;
const [localPart, domain] = email.split("@");
const maskedLocal = localPart.substring(0, 1) + "***";
return `${maskedLocal}@${domain}`;
}
/**
* Mascara CPF mantendo apenas os dois últimos dígitos
*/
function maskCPF(cpf: string): string {
if (!cpf) return cpf;
// Remove formatação
const cleaned = cpf.replace(/[^\d]/g, "");
if (cleaned.length === 11) {
return `***.***.***-${cleaned.slice(-2)}`;
}
if (cleaned.length === 14) {
// CNPJ
return `**.***.***/****-${cleaned.slice(-2)}`;
}
return "***.***.***-**";
}
/**
* Mascara telefone mantendo DDD e últimos 4 dígitos
*/
function maskPhone(phone: string): string {
if (!phone) return phone;
// Remove formatação
const cleaned = phone.replace(/[^\d]/g, "");
if (cleaned.length >= 10) {
const ddd = cleaned.substring(0, 2);
const lastFour = cleaned.slice(-4);
return `(${ddd}) *****-${lastFour}`;
}
return "**-****-****";
}
/**
* Mascara objeto recursivamente
*/
function maskSensitiveData(obj: any, options: MaskOptions = {}): any {
if (!obj || typeof obj !== "object") {
return obj;
}
// Se for string, verifica se é dado sensível
if (typeof obj === "string") {
// Email
if (obj.includes("@") && options.maskEmail) {
return maskEmail(obj);
}
// CPF/CNPJ
const digitsOnly = obj.replace(/[^\d]/g, "");
if (
(digitsOnly.length === 11 || digitsOnly.length === 14) &&
options.maskDocument
) {
return maskCPF(obj);
}
// Telefone
if (digitsOnly.length >= 10 && options.maskPhone) {
return maskPhone(obj);
}
return obj;
}
// Se for array
if (Array.isArray(obj)) {
return obj.map((item) => maskSensitiveData(item, options));
}
// Se for objeto
const masked: any = {};
for (const [key, value] of Object.entries(obj)) {
const lowerKey = key.toLowerCase();
// Identificar campos sensíveis
const isEmail = lowerKey.includes("email") || lowerKey === "mail";
const isDocument =
lowerKey.includes("cpf") ||
lowerKey.includes("cnpj") ||
lowerKey.includes("taxid");
const isPhone =
lowerKey.includes("phone") ||
lowerKey.includes("cellphone") ||
lowerKey.includes("telefone");
if (isEmail && options.maskEmail) {
masked[key] = maskEmail(String(value));
} else if (isDocument && options.maskDocument) {
masked[key] = maskCPF(String(value));
} else if (isPhone && options.maskPhone) {
masked[key] = maskPhone(String(value));
} else {
masked[key] = maskSensitiveData(value, options);
}
}
return masked;
}
/**
* Logger seguro que mascara dados sensíveis
*/
export const secureLogger = {
/**
* Log info com mascaramento automático
*/
info(message: string, data?: any) {
if (isProduction && data) {
const masked = maskSensitiveData(data, {
maskEmail: true,
maskDocument: true,
maskPhone: true,
});
console.log(`ℹ️ ${message}`, JSON.stringify(masked, null, 2));
} else {
console.log(`ℹ️ ${message}`, data ? JSON.stringify(data, null, 2) : "");
}
},
/**
* Log debug com mascaramento automático
*/
debug(message: string, data?: any) {
if (isProduction && data) {
const masked = maskSensitiveData(data, {
maskEmail: true,
maskDocument: true,
maskPhone: true,
});
console.debug(`🔍 ${message}`, JSON.stringify(masked, null, 2));
} else {
console.debug(`🔍 ${message}`, data ? JSON.stringify(data, null, 2) : "");
}
},
/**
* Log warning
*/
warn(message: string, data?: any) {
console.warn(`⚠️ ${message}`, data || "");
},
/**
* Log error
*/
error(message: string, error?: any) {
console.error(`❌ ${message}`, error || "");
},
/**
* Mascara dados manualmente
*/
mask(data: any, options?: MaskOptions) {
return maskSensitiveData(data, options);
},
};
+145
View File
@@ -0,0 +1,145 @@
import { z } from "zod";
import validator from "validator";
import { isValidDocument } from "./validators/document-validator";
import sanitizeHtml from "sanitize-html";
/**
* Schemas de validação usando Zod
* Garante type-safety e validação robusta de dados de entrada
*/
// Schema para email - validação RFC 5322
export const emailSchema = z
.string()
.min(1, "Email é obrigatório")
.email("Formato de email inválido")
.refine((email) => validator.isEmail(email), {
message: "Email inválido",
})
.transform((email) => email.trim().toLowerCase());
// Schema para telefone brasileiro
export const phoneSchema = z
.string()
.min(1, "Telefone é obrigatório")
.refine((phone) => {
// Remove todos os caracteres não numéricos
const cleaned = phone.replace(/[^\d]/g, "");
// Deve ter 10 ou 11 dígitos (telefone fixo ou celular com DDD)
return cleaned.length >= 10 && cleaned.length <= 11;
}, "Telefone deve ter 10 ou 11 dígitos (incluindo DDD)")
.transform((phone) => phone.replace(/[^\d]/g, ""));
// Schema para CPF/CNPJ
export const documentSchema = z
.string()
.min(1, "CPF/CNPJ é obrigatório")
.refine((doc) => isValidDocument(doc), {
message: "CPF ou CNPJ inválido",
})
.transform((doc) => doc.replace(/[^\d]/g, "")); // Normaliza removendo formatação
// Schema para nome (alfabético, sem números)
export const nameSchema = z
.string()
.min(3, "Nome deve ter no mínimo 3 caracteres")
.max(100, "Nome deve ter no máximo 100 caracteres")
.refine(
(name) => /^[A-Za-zÀ-ÿ\s'-]+$/.test(name.trim()),
"Nome deve conter apenas letras, espaços e acentos"
)
.transform((name) => name.trim());
// Schema para mensagem (sanitiza HTML e previne XSS)
export const messageSchema = z
.string()
.max(280, "Mensagem deve ter no máximo 280 caracteres")
.transform((message) => {
// Sanitiza HTML para prevenir XSS
const sanitized = sanitizeHtml(message, {
allowedTags: [], // Remove todas as tags HTML
allowedAttributes: {},
});
return sanitized.trim();
})
.optional();
// Schema para tier de doação
export const donationTierSchema = z.enum(
["coffee", "snack", "lunch", "custom"],
{
message: "Tier de doação inválido",
}
);
// Schema para valor customizado
export const customAmountSchema = z
.number()
.min(5, "Valor mínimo é R$ 5,00")
.max(500, "Valor máximo é R$ 500,00");
// Schema completo para criação de doação
export const createDonationSchema = z
.object({
tier: donationTierSchema,
anonymous: z.boolean().optional().default(false),
customAmount: z.number().optional(),
})
.and(
z.union([
// Doação anônima - não precisa de dados pessoais
z.object({
anonymous: z.literal(true),
}),
// Doação identificada - precisa de todos os dados
z.object({
anonymous: z.literal(false),
donorName: nameSchema,
donorEmail: emailSchema,
donorPhone: phoneSchema,
donorTaxId: documentSchema,
donorMessage: messageSchema,
}),
])
)
.refine(
(data) => {
// Se tier for custom, customAmount é obrigatório
if (data.tier === "custom") {
return data.customAmount !== undefined;
}
return true;
},
{
message: "customAmount é obrigatório quando tier é 'custom'",
path: ["customAmount"],
}
)
.refine(
(data) => {
// Se tier for custom, customAmount deve estar no range válido
if (data.tier === "custom" && data.customAmount) {
return data.customAmount >= 5 && data.customAmount <= 500;
}
return true;
},
{
message: "Valor deve estar entre R$ 5,00 e R$ 500,00",
path: ["customAmount"],
}
);
// Schema para verificação de status
export const verifyStatusSchema = z.object({
pixId: z.string().min(1, "pixId é obrigatório"),
});
// Schema para verificação de billing
export const verifyBillingSchema = z.object({
billingId: z.string().min(1, "ID da cobrança é obrigatório"),
});
// Type exports para uso no código
export type CreateDonationInput = z.infer<typeof createDonationSchema>;
export type VerifyStatusInput = z.infer<typeof verifyStatusSchema>;
export type VerifyBillingInput = z.infer<typeof verifyBillingSchema>;
+85
View File
@@ -0,0 +1,85 @@
/**
* Validador de documentos brasileiros (CPF e CNPJ)
* Implementa algoritmo completo de validação de dígitos verificadores
*/
export function isValidCPF(cpf: string): boolean {
// Remove caracteres não numéricos
const cleaned = cpf.replace(/[^\d]/g, "");
// Verifica se tem 11 dígitos
if (cleaned.length !== 11) return false;
// Verifica se todos os dígitos são iguais (ex: 111.111.111-11)
if (/^(\d)\1+$/.test(cleaned)) return false;
// Valida primeiro dígito verificador
let sum = 0;
for (let i = 0; i < 9; i++) {
sum += parseInt(cleaned.charAt(i)) * (10 - i);
}
let remainder = (sum * 10) % 11;
if (remainder === 10 || remainder === 11) remainder = 0;
if (remainder !== parseInt(cleaned.charAt(9))) return false;
// Valida segundo dígito verificador
sum = 0;
for (let i = 0; i < 10; i++) {
sum += parseInt(cleaned.charAt(i)) * (11 - i);
}
remainder = (sum * 10) % 11;
if (remainder === 10 || remainder === 11) remainder = 0;
if (remainder !== parseInt(cleaned.charAt(10))) return false;
return true;
}
export function isValidCNPJ(cnpj: string): boolean {
// Remove caracteres não numéricos
const cleaned = cnpj.replace(/[^\d]/g, "");
// Verifica se tem 14 dígitos
if (cleaned.length !== 14) return false;
// Verifica se todos os dígitos são iguais
if (/^(\d)\1+$/.test(cleaned)) return false;
// Valida primeiro dígito verificador
const weights1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
let sum = 0;
for (let i = 0; i < 12; i++) {
sum += parseInt(cleaned.charAt(i)) * weights1[i];
}
let remainder = sum % 11;
const firstDigit = remainder < 2 ? 0 : 11 - remainder;
if (firstDigit !== parseInt(cleaned.charAt(12))) return false;
// Valida segundo dígito verificador
const weights2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
sum = 0;
for (let i = 0; i < 13; i++) {
sum += parseInt(cleaned.charAt(i)) * weights2[i];
}
remainder = sum % 11;
const secondDigit = remainder < 2 ? 0 : 11 - remainder;
if (secondDigit !== parseInt(cleaned.charAt(13))) return false;
return true;
}
export function isValidDocument(document: string): boolean {
const cleaned = document.replace(/[^\d]/g, "");
if (cleaned.length === 11) {
return isValidCPF(document);
} else if (cleaned.length === 14) {
return isValidCNPJ(document);
}
return false;
}
export function normalizeDocument(document: string): string {
// Remove todos os caracteres não numéricos
return document.replace(/[^\d]/g, "");
}