Pular para o conteúdo principal

Segurança

Duas camadas protegem a comunicação: a assinatura HMAC (prova que o payload veio do Dunning e não foi adulterado) e a autenticação do request (credenciais que o Dunning apresenta ao seu endpoint). A primeira vai sempre; a segunda é opcional.

Headers e assinatura (padrão Kobana)​

Toda entrega é um POST com Content-Type: application/json e os headers abaixo — os mesmos em todos os produtos Kobana, então um único receptor atende vários produtos e ambientes e roteia com segurança:

HeaderValor
X-Kobana-Appdunning — o produto Kobana que enviou a entrega.
X-Kobana-Environmentdevelopment, sandbox ou production. Nunca deixe uma entrega sandbox tocar dados de produção.
X-Kobana-Webhook-IdId do endpoint cadastrado a que a entrega pertence.
X-Kobana-EventSlug do evento (ex.: charge.paid).
X-Kobana-Event-IdId do evento. Retries do mesmo evento repetem este valor — use-o para deduplicar.
X-Kobana-Delivery-IdId único desta tentativa de entrega. Faz parte do conteúdo assinado.
X-Kobana-TimestampUnix time (segundos) em que a entrega foi assinada. Faz parte do conteúdo assinado.
X-Kobana-Signaturev1,<base64 do HMAC> — um ou mais tokens separados por espaço.
webhook-id, webhook-timestamp, webhook-signatureOs mesmos três valores acima, no layout Standard Webhooks — verificáveis com uma biblioteca de prateleira.

Os headers legados X-Webhook-Event, X-Webhook-Event-Id, X-Webhook-Delivery-Id, X-Webhook-Attempt, X-Webhook-Timestamp e X-Webhook-Signature (t=<unix>,v1=<hex> sobre "{t}.{body}") continuam sendo enviados, deprecados, para integrações existentes. Migre para os X-Kobana-*: eles serão removidos numa próxima versão do contrato.

Como a assinatura é calculada​

chave = base64_decode(secret sem o prefixo "whsec_")
conteúdo = delivery_id + "." + timestamp + "." + body_cru
assinatura = "v1," + base64(HMAC_SHA256(chave, conteúdo))
  • body_cru é o corpo da requisição exatamente como recebido, byte a byte. Não parseie e re-serialize: ordem de chaves, espaços ou escapes mudariam e a assinatura deixaria de bater.
  • Amarrar o delivery_id ao conteúdo assinado impede que uma requisição capturada seja reapresentada com outro id para furar a sua deduplicação; o timestamp limita o replay no tempo.
  • O secret tem a forma whsec_ + base64 de 32 bytes aleatórios e é exibido uma única vez. Secrets antigos (sem o prefixo, ou com hexadecimal após ele) continuam válidos: a chave é o base64 decodificado após o prefixo, ou os bytes UTF-8 do secret quando não há prefixo.
  • Durante uma rotação de secret o header carrega um token por secret ainda válido (v1,AAA… v1,BBB…). A entrega é válida se qualquer token casar com qualquer secret que você tenha.

Verificando uma entrega​

  1. Leia webhook-id, webhook-timestamp e webhook-signature (ou os equivalentes X-Kobana-*). Rejeite a requisição se algum faltar.
  2. Rejeite se |agora − timestamp| > 300 segundos: cinco minutos absorvem diferença de relógio e limitam replays.
  3. Monte o conteúdo assinado com o body cru e calcule o HMAC esperado para cada secret que você confia.
  4. Compare com cada token v1, em tempo constante — nunca com ==, que devolve no primeiro byte diferente e vaza o digest por timing.
  5. Deduplique por X-Kobana-Event-Id para processar cada evento uma vez, ou por webhook-id para registrar cada tentativa.
  6. Responda 2xx rápido e faça o trabalho pesado de forma assíncrona. Qualquer outra resposta entra no ciclo de retry.
const crypto = require('node:crypto');

/**
* @param headers headers da requisição, com nomes em minúsculas
* @param rawBody body cru (string ou Buffer), ANTES de qualquer JSON.parse
* @param secrets secret atual (e o anterior, durante uma rotação)
*/
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")

Com Express, mantenha o body cru montando express.raw({ type: 'application/json' }) na rota do webhook (não express.json()), verifique e só então faça JSON.parse. Com Django/Flask, use request.body/request.get_data() — nunca o objeto já parseado.

Autenticação do request​

Se o seu endpoint fica atrás de um gateway que exige credencial, configure a autenticação no cadastro:

TipoO que o Dunning envia
Nenhuma (none)Só a assinatura HMAC
Basic (basic)Authorization: Basic <usuário:senha>
Bearer (bearer)Authorization: Bearer <token>
Header (header)Um header custom com nome e valor definidos por você

As credenciais são cifradas em repouso (AES-256-GCM) e nunca aparecem na trilha de entregas: na captura de request/response, a assinatura e os headers de autenticação são gravados como [REDACTED].

Rotação de secret​

O botão de rotação (na linha do endpoint) gera um secret novo, exibido uma única vez, e invalida o anterior imediatamente (também disponível via POST /v1/webhook-endpoints/{id}/rotate-secret). Rotacione se houver suspeita de vazamento ou como higiene periódica; atualize o consumidor no mesmo momento, pois entregas assinadas com o secret antigo deixarão de validar.

Proteções do lado do Dunning​

  • Só HTTPS: URLs http:// são recusadas no cadastro.
  • Anti-SSRF: a URL é validada no cadastro e revalidada a cada entrega (DNS re-resolvido, IP fixado, redes internas bloqueadas). Redirects não são seguidos. Um destino que passa a resolver para rede interna é bloqueado permanentemente (SSRF blocked, sem retry).
  • Respostas do seu endpoint são capturadas com headers sensíveis (Set-Cookie, Authorization...) redigidos.