Seguridad
Dos capas protegen la comunicación: la firma HMAC (prueba que el payload vino del Dunning y no fue adulterado) y la autenticación del request (credenciales que el Dunning presenta a tu endpoint). La primera va siempre; la segunda es opcional.
Firma HMAC
Toda entrega lleva el header:
X-Webhook-Signature: t=<unix>,v1=<hex>
tes el timestamp (unix, segundos) del momento del envío — cada reintento se re-firma con untnuevo.v1es el HMAC-SHA256 de"{t}.{body}"(timestamp, un punto y el body crudo de la solicitud), calculado con el secret del endpoint (aquelwhsec_...mostrado una única vez en el registro), en hexadecimal en minúsculas.
Vincular el timestamp a la firma cierra el replay: un atacante que capture una entrega no puede volver a presentarla más tarde, porque el t queda fuera de la ventana de tolerancia — y no puede cambiar el t sin invalidar el HMAC. Tolerancia recomendada: 5 minutos.
Verificación de la firma
Extrae t y v1, rechaza timestamps fuera de la tolerancia y compara el HMAC de "{t}.{body}" en tiempo constante (siempre sobre el body crudo, antes de cualquier parseo de JSON). El algoritmo es el mismo en cualquier lenguaje — el mismo verificador en Node.js, Python, PHP y Ruby:
- Node.js
- Python
- PHP
- Ruby
const crypto = require('crypto');
const TOLERANCE_SECONDS = 300; // 5 minutos
function verifySignature(rawBody, signatureHeader, secret) {
const t = /(?:^|,)t=(\d+)(?:,|$)/.exec(signatureHeader || '')?.[1];
const v1 = /(?:^|,)v1=([0-9a-f]+)(?:,|$)/i.exec(signatureHeader || '')?.[1];
if (!t || !v1) return false;
// Anti-replay: rechaza firmas fuera de la ventana de tolerancia
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(v1.toLowerCase(), 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Ejemplo con Express (nota el bodyParser crudo):
app.post('/webhooks/dunning', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifySignature(
req.body, // Buffer con el body crudo
req.get('X-Webhook-Signature'),
process.env.DUNNING_WEBHOOK_SECRET
);
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body);
// encola y responde rápido
res.status(200).end();
});
import hashlib
import hmac
import os
import re
import time
from flask import Flask, abort, request
app = Flask(__name__)
TOLERANCE_SECONDS = 300 # 5 minutos
def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
t_match = re.search(r'(?:^|,)t=(\d+)(?:,|$)', signature_header or '')
v1_match = re.search(r'(?:^|,)v1=([0-9a-f]+)(?:,|$)', signature_header or '', re.I)
if not t_match or not v1_match:
return False
t = int(t_match.group(1))
# Anti-replay: rechaza firmas fuera de la ventana de tolerancia
if abs(int(time.time()) - t) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode(),
f'{t}.'.encode() + raw_body, # concatena los bytes crudos del body
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, v1_match.group(1).lower())
@app.post('/webhooks/dunning')
def webhook():
raw = request.get_data() # body crudo, antes del parseo de JSON
ok = verify_signature(
raw,
request.headers.get('X-Webhook-Signature', ''),
os.environ['DUNNING_WEBHOOK_SECRET'],
)
if not ok:
abort(401)
# encola y responde rápido
return '', 200
<?php
const TOLERANCE_SECONDS = 300; // 5 minutos
function verify_signature(string $rawBody, string $signatureHeader, string $secret): bool
{
if (!preg_match('/(?:^|,)t=(\d+)(?:,|$)/', $signatureHeader, $tm)) {
return false;
}
if (!preg_match('/(?:^|,)v1=([0-9a-f]+)(?:,|$)/i', $signatureHeader, $vm)) {
return false;
}
$t = (int) $tm[1];
// Anti-replay: rechaza firmas fuera de la ventana de tolerancia
if (abs(time() - $t) > TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, strtolower($vm[1])); // comparación en tiempo constante
}
$rawBody = file_get_contents('php://input'); // body crudo, antes del parseo de JSON
$header = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if (!verify_signature($rawBody, $header, getenv('DUNNING_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
// encola y responde rápido
http_response_code(200);
require 'openssl'
require 'rack'
require 'sinatra'
TOLERANCE_SECONDS = 300 # 5 minutos
def verify_signature(raw_body, signature_header, secret)
t = signature_header&.match(/(?:^|,)t=(\d+)(?:,|$)/)&.captures&.first
v1 = signature_header&.match(/(?:^|,)v1=([0-9a-f]+)(?:,|$)/i)&.captures&.first
return false unless t && v1
# Anti-replay: rechaza firmas fuera de la ventana de tolerancia
return false if (Time.now.to_i - t.to_i).abs > TOLERANCE_SECONDS
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{t}.#{raw_body}")
# secure_compare es constante en el tiempo y ya maneja tamaños distintos
Rack::Utils.secure_compare(expected, v1.downcase)
end
# Ejemplo con Sinatra (body crudo, antes del parseo de JSON):
post '/webhooks/dunning' do
raw = request.body.read
unless verify_signature(raw, request.env['HTTP_X_WEBHOOK_SIGNATURE'], ENV['DUNNING_WEBHOOK_SECRET'])
halt 401
end
# encola y responde rápido
status 200
end
Trampas clásicas:
- No re-serialices el JSON para calcular el HMAC: cualquier diferencia de espacios u orden de claves cambia la firma. Usa el body exactamente como llegó.
- Concatena en el orden correcto: el HMAC cubre
t, un punto (.) y el body — en ese orden. - Usa comparación en tiempo constante (
crypto.timingSafeEqual), nunca===: la comparación simple filtra información por timing. - Un reloj desincronizado en tu servidor tumba verificaciones legítimas: mantén NTP al día antes de reducir la tolerancia.
- Rechaza solicitudes sin el header o con firma inválida con
401, sin detallar el motivo.
Un reintento de la misma entrega llega con t (y firma) diferentes — la ventana de tolerancia aplica a cualquier intento. Para deduplicar entregas, usa el header X-Webhook-Delivery-Id, no la firma.
Autenticación del request
Si tu endpoint está detrás de un gateway que exige credenciales, configura la autenticación en el registro:
| Tipo | Qué envía el Dunning |
|---|---|
Ninguna (none) | Solo la firma HMAC |
Basic (basic) | Authorization: Basic <usuário:senha> |
Bearer (bearer) | Authorization: Bearer <token> |
Header (header) | Un header custom con nombre y valor definidos por ti |
Las credenciales se cifran en reposo (AES-256-GCM) y nunca aparecen en la pista de entregas: en la captura de request/response, la firma y los headers de autenticación se graban como [REDACTED].
Rotación del secret
El botón de rotación (en la fila del endpoint) genera un secret nuevo, mostrado una única vez, e invalida el anterior de inmediato (también disponible vía POST /api/v1/webhook-endpoints/{id}/rotate-secret). Rota el secret si sospechas de una filtración o como higiene periódica; actualiza el consumidor en ese mismo momento, porque las entregas firmadas con el secret anterior dejarán de validar.
Protecciones del lado del Dunning
- Solo HTTPS: las URLs
http://se rechazan en el registro. - Anti-SSRF: la URL se valida en el registro y se revalida en cada entrega (DNS re-resuelto, IP fijada, redes internas bloqueadas). Los redirects no se siguen. Un destino que pasa a resolver hacia una red interna se bloquea permanentemente (
SSRF blocked, sin reintento). - Las respuestas de tu endpoint se capturan con los headers sensibles (
Set-Cookie,Authorization...) redactados.