Seguridad en Webhooks Financieros: Verificación, Replay Protection y Procesamiento Seguro

Descargo de responsabilidad: Este artículo es material educativo de seguridad aplicada a integraciones financieras. La implementación incorrecta de verificación de webhooks puede resultar en aceptación de eventos fraudulentos o pérdida de notificaciones legítimas. Validar siempre contra la documentación oficial vigente de cada proveedor de pagos. El uso de este contenido es bajo exclusiva responsabilidad del desarrollador u operador.

1. Por Qué los Webhooks Son el Vector de Ataque Más Subestimado

Los webhooks son el mecanismo mediante el cual pasarelas de pago notifican eventos asíncronos: confirmaciones de pago, chargebacks, reembolsos y cambios de estado de suscripciones. A diferencia de las respuestas síncronas de API que ocurren dentro de un flujo de request autenticado, los webhooks son peticiones HTTP entrantes no solicitadas desde servidores externos. Esto los convierte en objetivo prioritario para atacantes que buscan:

  • Falsificar eventos: Enviar webhook falso de «pago aprobado» para activar servicio sin pago real.
  • Replay attacks: Capturar webhook legítimo y reenviarlo múltiples veces para duplicar créditos/servicios.
  • Exfiltrar datos: Si el endpoint responde con información sensible en cuerpo de respuesta 200, atacante puede interceptarla.
  • Denegación de servicio: Saturar endpoint con requests masivos para bloquear procesamiento de eventos legítimos.

La defensa requiere múltiples capas: verificación criptográfica obligatoria, protección contra replay, procesamiento idempotente y respuesta mínima sin fuga de información. Ninguna capa es opcional en producción financiera.

2. Verificación HMAC-SHA256: Implementación Correcta y Errores Fatales

Toda pasarela seria firma webhooks con HMAC-SHA256 usando secreto compartido. Pero implementaciones incorrectas son más comunes que las correctas. Los errores fatales incluyen:

  • Usar payload parseado en lugar de crudo: json_decode() + json_encode() altera orden de claves, espacios y formato numérico. La firma se calcula sobre bytes exactos recibidos (file_get_contents('php://input')).
  • Comparación con === en lugar de hash_equals(): Operador === es vulnerable a timing attacks. Atacante mide tiempo de comparación byte por byte para forjar firma válida.
  • No normalizar header de firma: Algunos proveedores incluyen prefijo (sha256=, v1=) o timestamp concatenado. Extraer solo el hash antes de comparar.
  • Aceptar requests sin header de firma: Endpoint debe rechazar 401/403 inmediatamente si header ausente. Nunca procesar «por compatibilidad».
<?php
declare(strict_types=1);

namespace App\Webhooks\Security;

class WebhookVerifier 
{
    private string $secret;
    private int $timestampTolerance;

    public function __construct(string $secret, int $timestampToleranceSeconds = 300) 
    {
        if (strlen($secret) < 16) {
            throw new \InvalidArgumentException("Webhook secret too short (min 16 bytes)");
        }
        $this->secret = $secret;
        $this->timestampTolerance = $timestampToleranceSeconds;
    }

    /**
     * Verifica firma HMAC + timestamp para prevenir replay attacks
     * Compatible con Stripe, Mercado Pago, Kushki, dLocal
     */
    public function verify(string $rawPayload, string $signatureHeader): VerificationResult 
    {
        // Paso 1: Parsear header según formato del proveedor
        $parsed = $this->parseSignatureHeader($signatureHeader);
        
        if ($parsed === null) {
            return new VerificationResult(false, 'INVALID_SIGNATURE_FORMAT');
        }

        // Paso 2: Validar timestamp (prevención de replay)
        $age = abs(time() - $parsed['timestamp']);
        if ($age > $this->timestampTolerance) {
            return new VerificationResult(false, 'TIMESTAMP_OUT_OF_TOLERANCE');
        }

        // Paso 3: Recalcular firma esperada sobre payload CRUDO
        // Formato típico: timestamp.payload (Stripe-like)
        $signedContent = $parsed['timestamp'] . '.' . $rawPayload;
        $expectedSignature = hash_hmac('sha256', $signedContent, $this->secret);

        // Paso 4: Comparación de tiempo constante OBLIGATORIA
        if (!hash_equals($expectedSignature, $parsed['signature'])) {
            return new VerificationResult(false, 'SIGNATURE_MISMATCH');
        }

        return new VerificationResult(true, null);
    }

    /**
     * Parsea header de firma multi-formato
     * Stripe: t=1697385600,v1=abc123...
     * MP/Kushki: abc123... (directo)
     */
    private function parseSignatureHeader(string $header): ?array 
    {
        // Formato Stripe-like con timestamp
        if (preg_match('/t=(\d+),v1=([a-f0-9]+)/', $header, $matches)) {
            return [
                'timestamp' => (int) $matches[1],
                'signature' => $matches[2]
            ];
        }

        // Formato directo (sin timestamp) → usar tiempo actual como fallback
        // ADVERTENCIA: Sin timestamp nativo, replay protection depende de nonce/idempotencia
        if (preg_match('/^[a-f0-9]{64}$/', trim($header))) {
            return [
                'timestamp' => time(),
                'signature' => trim($header)
            ];
        }

        return null;
    }
}

readonly class VerificationResult 
{
    public function __construct(
        public bool $isValid,
        public ?string $failureReason
    ) {}
}
ProveedorHeaderFormato FirmaTimestamp NativoTolerancia Recomendada
StripeStripe-Signaturet={ts},v1={sig}Sí300s
Mercado PagoX-SignatureHMAC directoNoN/A (usar event_id)
KushkiX-Kushki-SignatureHMAC directoNoN/A (usar transaction_id)
dLocalX-dLocal-SignatureHMAC + tsSí300s
PayPalPAYPAL-TRANSMISSION-SIGJWT RS256Sí (en JWT)Verificar exp claim

3. Prevención de Replay Attacks: Timestamps + Nonces + Idempotencia

La verificación HMAC sola no previene replay attacks si el atacante captura un webhook válido dentro de la ventana de tolerancia temporal. Se requieren mecanismos adicionales:

  1. Timestamp tolerance estricta: Rechazar webhooks con timestamp >5 minutos de antigüedad. Reduce ventana de replay a segundos.
  2. Nonce/event_id tracking: Almacenar IDs de eventos procesados en Redis con TTL = 2x tolerancia temporal. Si ID ya existe, descartar silenciosamente (retornar 200 para evitar reintentos del proveedor).
  3. Idempotencia en procesamiento: Incluso si webhook pasa verificación y no es replay detectado, la lógica de negocio debe ser idempotente. Misma transacción procesada dos veces = mismo resultado sin efectos secundarios duplicados.
<?php
// Middleware completo de seguridad para endpoint webhook

$rawBody = file_get_contents('php://input');
$signatureHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';

// 1. Verificar firma + timestamp
$verifier = new WebhookVerifier(getenv('STRIPE_WEBHOOK_SECRET'));
$verification = $verifier->verify($rawBody, $signatureHeader);

if (!$verification->isValid) {
    http_response_code(403);
    error_log("[WEBHOOK_REJECTED] Reason: {$verification->failureReason} | IP: {$_SERVER['REMOTE_ADDR']}");
    exit; // Sin cuerpo descriptivo
}

// 2. Parsear evento DESPUÉS de verificación exitosa
$event = json_decode($rawBody, true);
$eventId = $event['id'] ?? null;

if (!$eventId) {
    http_response_code(400);
    exit;
}

// 3. Check de replay vía Redis (deduplicación)
$redis = new Redis();
$redis->connect('redis-internal', 6379);
$dedupKey = "webhook:processed:{$eventId}";

if ($redis->exists($dedupKey)) {
    // Ya procesado: retornar 200 para evitar reintentos del proveedor
    http_response_code(200);
    echo '{"status":"already_processed"}';
    exit;
}

// 4. Marcar como procesado ANTES de ejecutar lógica de negocio
// Previene race condition en procesamiento concurrente
$redis->setex($dedupKey, 600, '1'); // TTL 10 min > tolerancia timestamp

// 5. Encolar para procesamiento asíncrono (no procesar inline)
$redis->lPush('queue:webhooks', json_encode([
    'event_id' => $eventId,
    'type' => $event['type'],
    'raw_payload' => $rawBody,
    'received_at' => time()
]));

// 6. Responder INMEDIATAMENTE (<100ms)
http_response_code(200);
header('Content-Type: application/json');
echo '{"status":"accepted"}';
exit;

4. Procesamiento Seguro: Principio de Mínimo Privilegio

El worker que procesa webhooks encolados debe operar con privilegios mínimos y aislamiento estricto:

  • Sin acceso a red externa innecesaria: Worker de webhooks no necesita llamar APIs externas salvo las estrictamente necesarias para reconciliación. Firewall egress restringido.
  • Credenciales específicas: Token de BD con permisos INSERT/UPDATE solo en tablas de eventos y ledger. Sin DROP/ALTER/GRANT. Sin acceso a tablas de usuarios/PII.
  • Timeouts estrictos: Cada job tiene timeout máximo (ej: 30s). Jobs que exceden timeout se marcan como fallidos y reencolan con backoff. Previenen workers zombies.
  • Logging estructurado sin PII: Loguear event_id, type, status, duración. Nunca loguear payload completo ni datos de clientes. Sanitización automática obligatoria.

5. Auditoría Forense y Retención de Evidencia

En disputas de chargeback o investigaciones regulatorias, los webhooks son evidencia primaria. La retención y estructura de logs determina si puedes defender tu posición:

  • Almacenar payload crudo firmado: Guardar body original + signature header + timestamp de recepción. Permite re-verificar firma meses después si secreto rota.
  • Registrar decisión de verificación: Log inmutable de ACCEPT/REJECT con razón. Esencial para demostrar que sistema rechazó eventos sospechosos proactivamente.
  • Vincular a transacción interna: Cada webhook procesado debe referenciar transaction_id interno. Trazabilidad bidireccional: desde webhook → transacción y desde transacción → todos los webhooks relacionados.
  • Retención regulatoria: Mínimo 5 años en LATAM para eventos financieros. Archivar a cold storage (S3/R2) tras 90 días hot. Formato Parquet/ORC para consultas analíticas eficientes.

6. Testing de Seguridad de Webhooks

Verificar que la implementación resiste ataques reales requiere testing específico:

  • Firma inválida: Enviar webhook con firma modificada en 1 bit. Debe retornar 403 sin procesar.
  • Timestamp expirado: Enviar webhook válido pero con timestamp de hace 10 minutos. Debe retornar 403.
  • Replay inmediato: Enviar mismo webhook válido dos veces en <1s. Primera vez: procesar. Segunda: retornar 200 sin re-procesar.
  • Payload malformado: JSON inválido, campos faltantes, tipos incorrectos. Debe retornar 400 sin crash ni stack trace expuesto.
  • Flood test: 1000 requests/segundo durante 1 minuto. Endpoint debe mantener latencia <100ms y no perder eventos legítimos.

Automatizar estos tests en CI/CD. Ejecutar semanalmente en staging con secretos de prueba. Cualquier regresión bloquea deploy.

7. Conclusión y Recursos Oficiales

La seguridad de webhooks financieros es disciplina de defensa en profundidad: verificación criptográfica correcta previene falsificación, timestamps y nonces previenen replay, idempotencia previene duplicación, y auditoría inmutable permite defensa legal post-incidente. Implementar solo una de estas capas deja vulnerabilidades explotables. En fintech, donde cada webhook representa dinero real moviéndose entre cuentas, la seguridad del endpoint de notificaciones es tan crítica como la del propio ledger.

Referencias oficiales:


Nota técnica: Este artículo utiliza marcado Schema.org Article coexistente con el nodo FinancialService corporativo. Los metadatos semánticos se indexan automáticamente vía The SEO Framework.

Deja un comentario