Ir para o conteúdo

Validação HMAC

Toda entrega de webhook traz três headers:

X-Aceitou-Signature: sha256=<hex-lowercase>
X-Aceitou-Event: document_sent
X-Aceitou-Delivery-Id: 1234567890

A assinatura é HMAC-SHA256(secret, raw_body) em hex lowercase, prefixada por sha256=.

X-Aceitou-Delivery-Id é o identificador único da entrega — quando o nosso WebhookRetryWorker retenta um delivery falhada, o mesmo id é reenviado. Use pra deduplicar no seu lado (cache de IDs vistos por N horas e ignorar duplicatas).

Sempre valide

Sem validação, qualquer um que descobrir sua URL pode forjar eventos. Valide antes de qualquer processamento — incluindo logs.

Regras pra validação correta

  1. Use o body cru (raw bytes, não JSON re-serializado). Reserializar muda o hash.
  2. Comparação de tempo constante (timingSafeEqual/compare_digest/hash_equals). Comparação == vaza timing.
  3. Resposta 401 se inválido. Não retorne 200 pra eventos não-autenticados.

Implementações

import crypto from "node:crypto";
import express from "express";

function verify(rawBody, signatureHeader, secret) {
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(rawBody, "utf8")
    .digest("hex");
  const a = Buffer.from(signatureHeader);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post(
  "/webhooks/aceitou",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.header("X-Aceitou-Signature");
    if (!sig || !verify(req.body, sig, process.env.ACEITOU_WEBHOOK_SECRET)) {
      return res.status(401).send("invalid signature");
    }
    const event = req.header("X-Aceitou-Event");
    const payload = JSON.parse(req.body.toString("utf8"));
    // processe...
    res.status(200).send();
  },
);
import hmac
import hashlib
import os
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
SECRET = os.environ["ACEITOU_WEBHOOK_SECRET"]

def verify(raw_body: bytes, signature_header: str) -> bool:
    expected = "sha256=" + hmac.new(
        SECRET.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature_header, expected)

@app.post("/webhooks/aceitou")
async def webhook(request: Request):
    raw = await request.body()
    sig = request.headers.get("X-Aceitou-Signature", "")
    if not verify(raw, sig):
        raise HTTPException(401, "invalid signature")
    # event = request.headers.get("X-Aceitou-Event")
    # payload = json.loads(raw)
    return {"ok": True}
function verifyAceitouSignature($rawBody, $signatureHeader, $secret) {
    $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
    return hash_equals($signatureHeader, $expected);
}

$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_ACEITOU_SIGNATURE'] ?? '';
if (!verifyAceitouSignature($raw, $sig, getenv('ACEITOU_WEBHOOK_SECRET'))) {
    http_response_code(401);
    exit('invalid signature');
}

$payload = json_decode($raw, true);
// processe...
http_response_code(200);
func verify(body []byte, signatureHeader, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(body)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(signatureHeader), []byte(expected))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    sig := r.Header.Get("X-Aceitou-Signature")
    if !verify(body, sig, os.Getenv("ACEITOU_WEBHOOK_SECRET")) {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }
    // processe...
    w.WriteHeader(http.StatusOK)
}
using System.Security.Cryptography;
using System.Text;

app.MapPost("/webhooks/aceitou", async (HttpContext ctx) =>
{
    using var ms = new MemoryStream();
    await ctx.Request.Body.CopyToAsync(ms);
    var raw = ms.ToArray();

    var sig = ctx.Request.Headers["X-Aceitou-Signature"].ToString();
    var secret = Environment.GetEnvironmentVariable("ACEITOU_WEBHOOK_SECRET")!;

    if (!Verify(raw, sig, secret))
        return Results.Unauthorized();

    // var ev = ctx.Request.Headers["X-Aceitou-Event"].ToString();
    return Results.Ok();
});

static bool Verify(byte[] rawBody, string signatureHeader, string secret)
{
    var hash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), rawBody);
    var expected = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(signatureHeader),
        Encoding.UTF8.GetBytes(expected));
}

Testando localmente

Use ngrok ou Cloudflare Tunnel pra expor seu localhost com HTTPS:

ngrok http 3000
# → forwarding: https://abc123.ngrok.io -> http://localhost:3000

Cadastre o webhook apontando pra URL do ngrok. Quando estiver verde, troque pelo seu domínio real em homologação.

A Aceitou só entrega em HTTPS. URLs http:// são rejeitadas na criação.