Saltar al contenido principal

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.

Headers y firma (estándar Kobana)​

Toda entrega es un POST con Content-Type: application/json y los headers de abajo — los mismos en todos los productos Kobana, así un único receptor atiende varios productos y ambientes y enruta con seguridad:

HeaderValor
X-Kobana-Appdunning — el producto Kobana que envió la entrega.
X-Kobana-Environmentdevelopment, sandbox o production. Nunca dejes que una entrega sandbox toque datos de producción.
X-Kobana-Webhook-IdId del endpoint registrado al que pertenece la entrega.
X-Kobana-EventSlug del evento (ej.: charge.paid).
X-Kobana-Event-IdId del evento. Los reintentos del mismo evento lo repiten — úsalo para deduplicar.
X-Kobana-Delivery-IdId único de este intento de entrega. Forma parte del contenido firmado.
X-Kobana-TimestampUnix time (segundos) en que se firmó la entrega. Forma parte del contenido firmado.
X-Kobana-Signaturev1,<base64 del HMAC> — uno o más tokens separados por espacio.
webhook-id, webhook-timestamp, webhook-signatureLos mismos tres valores, en el layout Standard Webhooks — verificables con una biblioteca estándar.

Los headers legados X-Webhook-Event, X-Webhook-Event-Id, X-Webhook-Delivery-Id, X-Webhook-Attempt, X-Webhook-Timestamp y X-Webhook-Signature (t=<unix>,v1=<hex> sobre "{t}.{body}") se siguen enviando, deprecados, para integraciones existentes. Migra a los X-Kobana-*: se eliminarán en una próxima versión del contrato.

Cómo se calcula la firma​

clave = base64_decode(secret sin el prefijo "whsec_")
contenido = delivery_id + "." + timestamp + "." + body_crudo
firma = "v1," + base64(HMAC_SHA256(clave, contenido))
  • body_crudo es el cuerpo de la request exactamente como se recibió, byte a byte. No lo parsees y re-serialices: el orden de claves, espacios o escapes cambiarían y la firma dejaría de coincidir.
  • Atar el delivery_id al contenido firmado impide que una request capturada se reenvíe con otro id para saltarse tu deduplicación; el timestamp limita el replay en el tiempo.
  • El secret tiene la forma whsec_ + base64 de 32 bytes aleatorios y se muestra una sola vez. Los secrets antiguos (sin prefijo, o con hexadecimal tras él) siguen siendo válidos: la clave es el base64 decodificado tras el prefijo, o los bytes UTF-8 del secret cuando no hay prefijo.
  • Durante una rotación de secret el header lleva un token por cada secret aún válido (v1,AAA… v1,BBB…). La entrega es válida si cualquier token coincide con cualquier secret que tengas.

Verificando una entrega​

  1. Lee webhook-id, webhook-timestamp y webhook-signature (o los equivalentes X-Kobana-*). Rechaza la request si falta alguno.
  2. Rechaza si |ahora − timestamp| > 300 segundos: cinco minutos absorben la desviación de reloj y limitan los replays.
  3. Arma el contenido firmado con el body crudo y calcula el HMAC esperado para cada secret en el que confíes.
  4. Compara con cada token v1, en tiempo constante — nunca con ==, que devuelve en el primer byte distinto y filtra el digest por timing.
  5. Deduplica por X-Kobana-Event-Id para procesar cada evento una vez, o por webhook-id para registrar cada intento.
  6. Responde 2xx rápido y haz el trabajo pesado de forma asíncrona. Cualquier otra respuesta entra en el ciclo de reintentos.
const crypto = require('node:crypto');

/**
* @param headers headers de la request, con nombres en minúsculas
* @param rawBody body crudo (string o Buffer), ANTES de cualquier JSON.parse
* @param secrets secret actual (y el anterior, durante una rotación)
*/
function verifyKobanaWebhook(headers, rawBody, secrets) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signature = headers['webhook-signature'];
if (!id || !timestamp || !signature) throw new Error('missing signature headers');
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) throw new Error('timestamp out of tolerance');

const content = `${id}.${timestamp}.${rawBody}`;
const received = signature.split(' ').filter((t) => t.startsWith('v1,')).map((t) => Buffer.from(t.slice(3), 'base64'));
for (const secret of [].concat(secrets)) {
const key = secret.startsWith('whsec_') ? Buffer.from(secret.slice(6), 'base64') : Buffer.from(secret, 'utf8');
const expected = crypto.createHmac('sha256', key).update(content).digest();
if (received.some((r) => r.length === expected.length && crypto.timingSafeEqual(r, expected))) return true;
}
throw new Error('no matching signature');
}
import base64, hashlib, hmac, time

def verify_kobana_webhook(headers, raw_body: bytes, secrets) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signature = headers.get("webhook-signature")
if not (msg_id and timestamp and signature):
raise ValueError("missing signature headers")
if abs(time.time() - int(timestamp)) > 300:
raise ValueError("timestamp out of tolerance")
content = f"{msg_id}.{timestamp}.".encode() + raw_body
received = [base64.b64decode(t[3:]) for t in signature.split(" ") if t.startswith("v1,")]
for secret in ([secrets] if isinstance(secrets, str) else secrets):
key = base64.b64decode(secret[6:]) if secret.startswith("whsec_") else secret.encode()
expected = hmac.new(key, content, hashlib.sha256).digest()
if any(hmac.compare_digest(r, expected) for r in received):
return True
raise ValueError("no matching signature")

Con Express, conserva el body crudo montando express.raw({ type: 'application/json' }) en la ruta del webhook (no express.json()), verifica y solo entonces haz JSON.parse. Con Django/Flask usa request.body/request.get_data() — nunca el objeto ya parseado.

Autenticación del request​

Si tu endpoint está detrás de un gateway que exige credenciales, configura la autenticación en el registro:

TipoQué 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 /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.