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.
Assinatura HMAC
Toda entrega leva o header:
X-Webhook-Signature: t=<unix>,v1=<hex>
té o timestamp (unix, segundos) do momento do envio — cada retry é re-assinado com umtnovo.v1é o HMAC-SHA256 de"{t}.{body}"(timestamp, um ponto e o body cru da requisição), calculado com o secret do endpoint (aquelewhsec_...exibido uma única vez no cadastro), em hexadecimal minúsculo.
Vincular o timestamp à assinatura fecha o replay: um atacante que capture uma entrega não consegue reapresentá-la mais tarde, porque o t fica fora da janela de tolerância — e não consegue trocar o t sem invalidar o HMAC. Tolerância recomendada: 5 minutos.
Verificando a assinatura
Extraia t e v1, rejeite timestamps fora da tolerância e compare o HMAC de "{t}.{body}" em tempo constante (sempre sobre o body bruto, antes de qualquer parse de JSON). O algoritmo é o mesmo em qualquer linguagem — o mesmo verificador em Node.js, Python, PHP e 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: rejeita assinaturas fora da janela de tolerância
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);
}
// Exemplo com Express (repare no bodyParser cru):
app.post('/webhooks/dunning', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifySignature(
req.body, // Buffer com o body cru
req.get('X-Webhook-Signature'),
process.env.DUNNING_WEBHOOK_SECRET
);
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body);
// enfileire e responda 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: rejeita assinaturas fora da janela de tolerância
if abs(int(time.time()) - t) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode(),
f'{t}.'.encode() + raw_body, # concatena os bytes crus do 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 cru, antes do parse de JSON
ok = verify_signature(
raw,
request.headers.get('X-Webhook-Signature', ''),
os.environ['DUNNING_WEBHOOK_SECRET'],
)
if not ok:
abort(401)
# enfileire e responda 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: rejeita assinaturas fora da janela de tolerância
if (abs(time() - $t) > TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, strtolower($vm[1])); // comparação em tempo constante
}
$rawBody = file_get_contents('php://input'); // body cru, antes do parse de JSON
$header = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if (!verify_signature($rawBody, $header, getenv('DUNNING_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
// enfileire e responda 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: rejeita assinaturas fora da janela de tolerância
return false if (Time.now.to_i - t.to_i).abs > TOLERANCE_SECONDS
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{t}.#{raw_body}")
# secure_compare é constante no tempo e já trata tamanhos diferentes
Rack::Utils.secure_compare(expected, v1.downcase)
end
# Exemplo com Sinatra (body cru, antes do parse 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
# enfileire e responda rápido
status 200
end
Armadilhas clássicas:
- Não re-serialize o JSON para calcular o HMAC: qualquer diferença de espaço ou ordem de chaves muda a assinatura. Use o body exatamente como chegou.
- Concatene na ordem certa: o HMAC cobre
t, um ponto (.) e o body — nessa ordem. - Use comparação em tempo constante (
crypto.timingSafeEqual), nunca===: comparação simples vaza informação por timing. - Relógio dessincronizado no seu servidor derruba verificações legítimas: mantenha NTP em dia antes de reduzir a tolerância.
- Rejeite requisições sem o header ou com assinatura inválida com
401, sem detalhar o motivo.
Um retry da mesma entrega chega com t (e assinatura) diferentes — a janela de tolerância vale para qualquer tentativa. Para deduplicar entregas, use o header X-Webhook-Delivery-Id, não a assinatura.
Autenticação do request
Se o seu endpoint fica atrás de um gateway que exige credencial, configure a autenticação no cadastro:
| Tipo | O 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 /api/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.