Skip to content

Entrega de Webhook de Saída ​

Webhooks de saída notificam sistemas de terceiros quando eventos ocorrem na sua aplicação. As principais preocupações de segurança são SSRF (enviar requisições para infraestrutura interna), vazamento de segredo e integridade de assinatura.

Componentes principais ​

  • Registro de endpoints: armazena a URL, filtro de evento e um segredo em hash por assinante.
  • Fila de entregas: um registro por par (endpoint, evento), rastreando contagem de tentativas e status.
  • Assinador: gera assinaturas HMAC-SHA256 que o receptor pode verificar.
  • Validador de URL: bloqueia alvos SSRF antes de armazenar endpoints.

Schema ​

sql
CREATE TABLE webhook_endpoints (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    url         TEXT    NOT NULL,
    event_type  TEXT    NOT NULL,
    secret_hash TEXT    NOT NULL,       -- SHA-256 do segredo bruto; segredo bruto nunca armazenado
    max_retries INTEGER NOT NULL DEFAULT 3,
    active      INTEGER NOT NULL DEFAULT 1,
    created_at  TEXT    NOT NULL
);

CREATE TABLE webhook_deliveries (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    endpoint_id   INTEGER NOT NULL,
    event_type    TEXT    NOT NULL,
    payload       TEXT    NOT NULL,
    status        TEXT    NOT NULL DEFAULT 'pending',  -- pending | delivered | failed
    attempt_count INTEGER NOT NULL DEFAULT 0,
    last_status   INTEGER,                             -- último código de resposta HTTP
    last_error    TEXT,
    delivered_at  TEXT,
    created_at    TEXT    NOT NULL,
    updated_at    TEXT    NOT NULL
);

Apenas o hash SHA-256 do segredo é armazenado. O segredo bruto nunca é persistido — se o banco for comprometido, hashes não podem ser revertidos para falsificar assinaturas (SHA-256 sem HMAC não é reversível para um segredo aleatório de 32 bytes).

Formato de assinatura ​

X-Webhook-Signature: sha256={hex}
X-Webhook-Timestamp: {unix_timestamp}

Conteúdo assinado: {timestamp}.{body} — vinculando a assinatura ao payload e a um ponto no tempo.

php
public function sign(string $rawSecret, string $body, string $timestamp): string
{
    $payload = $timestamp . '.' . $body;
    $mac     = hash_hmac('sha256', $payload, $rawSecret);

    return 'sha256=' . $mac;
}

Incluir o timestamp no conteúdo assinado previne ataques de replay: um atacante que captura um webhook válido não pode reutilizá-lo mais tarde porque o timestamp seria antigo. Os receptores devem rejeitar assinaturas com mais de um limite (por exemplo, 5 minutos).

Prevenção de SSRF ​

Valide cada URL de webhook antes de armazená-la. No mínimo, bloqueie:

php
final class UrlValidator
{
    public function validate(string $url): ?string
    {
        // Bloquear injeção CRLF/null byte
        if (str_contains($url, "\n") || str_contains($url, "\r") || str_contains($url, "\0")) {
            return 'URL contains illegal control characters.';
        }

        // Apenas HTTPS
        if (strtolower(parse_url($url, PHP_URL_SCHEME) ?? '') !== 'https') {
            return 'Only HTTPS URLs are allowed.';
        }

        // Bloquear IPs privados/loopback e hostnames reservados
        // ...
    }
}

Faixas IPv4 privadas a bloquear: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 0.0.0.0.

Hostnames a bloquear: localhost, *.local, *.internal, *.test, *.invalid.

IPv6: ::1, fc00::/7 (ULA), fe80::/10 (link-local).

DNS rebinding: validar a URL no momento do registro não é suficiente — o registro DNS pode mudar entre o registro e a entrega para apontar para um IP interno. Para produção, também valide o IP resolvido no momento da entrega antes de abrir a conexão TCP.

Filtragem de resposta — nunca exponha segredos ​

O método toArray() em WebhookEndpoint deve omitir tanto secret quanto secret_hash:

php
public function toArray(): array
{
    return [
        'id', 'url', 'event_type', 'max_retries', 'active', 'created_at',
        // secret_hash intencionalmente ausente
    ];
}

Isso se aplica a: GET /webhooks/{id}, listar endpoints e qualquer log de auditoria que registre metadados de endpoint.

Lógica de retry ​

php
public function markFailed(int $id, string $error, ?int $httpStatus, string $now, int $maxRetries): ?WebhookDelivery
{
    $newCount  = $delivery->attemptCount + 1;
    $newStatus = $newCount >= $maxRetries ? 'failed' : 'pending';

    $this->executor->execute(
        'UPDATE webhook_deliveries SET status = ?, attempt_count = ?, last_error = ?, updated_at = ? WHERE id = ?',
        [$newStatus, $newCount, $error, $now, $id],
    );
}
  • attempt_count < max_retries → status permanece pending → worker busca novamente.
  • attempt_count >= max_retries → status vira failed → sem mais retentativas.

Workers devem implementar backoff exponencial (por exemplo, 2^attempt_count segundos) para evitar sobrecarregar um receptor com dificuldades.

Desativação ​

Endpoints desativados (active = 0) são excluídos da query de fan-out no momento do despacho:

sql
SELECT * FROM webhook_endpoints WHERE event_type = ? AND active = 1

Isso dá aos assinantes uma forma de pausar a entrega sem deletar o registro.

Decisões de design ​

Por que armazenar secret_hash em vez do segredo bruto? Se o banco for comprometido, o atacante não pode extrair segredos para falsificar assinaturas de webhook enviadas aos receptores. O segredo bruto é retornado uma vez na criação e deve ser armazenado com segurança pelo chamador.

Por que incluir timestamp na assinatura? Assinaturas sem timestamps são reproduzíveis indefinidamente. Incluir {timestamp}.{body} no HMAC significa que um atacante que intercepta um webhook não pode reenviá-lo — os receptores podem rejeitar timestamps fora de uma janela de ±5 minutos.

Por que validar URL no registro, não no despacho? Bloquear URLs inválidas no registro dá feedback imediato ao assinante e previne que dados ruins entrem na fila de entrega. Ataques de DNS rebinding requerem validação adicional no momento do despacho.

Publicado sob a licença MIT.