Saltar para a documentação
PHP

Verificar uma entrega

`OpenEmail::verifyWebhookSignature`: em tempo constante, com uma janela de repetição, e devolve o evento já analisado.

Num handler de pedidos

Um URL de webhook é público. Qualquer coisa na internet lhe pode enviar por POST um JSON com a forma certa, por isso um handler que lê o type do evento sem verificar a assinatura é uma API de escrita aberta.

webhook.php
use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail; $payload = (string) file_get_contents('php://input'); try {    $event = OpenEmail::verifyWebhookSignature(        $payload,        $_SERVER,        (string) getenv('OPENEMAIL_WEBHOOK_SECRET'),        toleranceSeconds: 300,    );} catch (WebhookSignatureException) {    http_response_code(400);     return;} error_log($event['type'] . ' ' . $event['id']);http_response_code(204);

Passe o corpo EM BRUTO, como string. Descodificá-lo e voltar a codificá-lo altera a ordem das chaves e os espaços em branco, e a assinatura deixa de corresponder, e é por isso que o payload tem o tipo string: um array, como o resultado de json_decode() ou a entrada já analisada por um framework, dá um TypeError em vez de ser verificado. Os cabeçalhos podem ser um array com o nome do cabeçalho em maiúsculas ou minúsculas, como o que getallheaders() devolve, $_SERVER, onde o cabeçalho chega como HTTP_X_OPENEMAIL_SIGNATURE, qualquer iterável, como um HeaderBag do Symfony ou do Laravel, ou um pedido PSR-7. Um valor que seja um array é lido a partir do primeiro elemento.

Duas coisas que isto trata e que uma verificação feita à mão costuma não tratar: compara o MAC em tempo constante com hash_equals(), para que o prefixo correto não possa ser recuperado cronometrando-o, e rejeita uma entrega com mais de toleranceSeconds: de diferença em qualquer das direções, cinco minutos salvo indicação em contrário, para que um pedido capturado não possa ser reutilizado para sempre. toleranceSeconds: 0 desliga a verificação de repetição. Ambos os bugs são silenciosos. Um handler com qualquer um deles passa em todos os testes que lhe ocorreria escrever.

Lança OpenEmail\Exception\WebhookSignatureException em todas as falhas: um cabeçalho X-OpenEmail-Signature em falta, um que não esteja na forma t=<seconds>,v1=<hex>, um carimbo temporal fora da janela, uma assinatura que não corresponde, ou um corpo assinado que não é um objeto JSON. Em caso de sucesso, devolve o corpo descodificado num array com chaves em camelCase, com id, type, createdAt e data, por isso não há um segundo json_decode() para errar.

Um segredo vazio lança antes OpenEmail\Exception\InvalidArgumentException, porque isso é um erro na sua configuração e não uma entrega inválida, e o (string) antes de getenv() transforma uma variável não definida exatamente nisso. Apanhe apenas WebhookSignatureException e responda 400. Assim, um segredo em falta termina o script com uma exceção não apanhada, a que o PHP em produção responde com um 500, e a entrega é tentada de novo depois de o corrigir, em vez de todos os eventos serem rejeitados como falsificados.

A verificação assenta em hash_hmac() e hash_equals(), que todas as compilações de PHP têm, por isso não precisa de nenhuma extensão. O exemplo faz return em vez de chamar exit, por isso as mesmas linhas funcionam também dentro de uma função ou de um front controller.

Numa aplicação PSR-7

Com um pedido PSR-7, como o que o Slim e o Mezzio lhe entregam, passe o próprio pedido como cabeçalhos e o seu corpo como string.

webhook_handler.php
use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail;use Psr\Http\Message\ResponseFactoryInterface;use Psr\Http\Message\ResponseInterface;use Psr\Http\Message\ServerRequestInterface; function receiveOpenEmail(ServerRequestInterface $request, ResponseFactoryInterface $responses): ResponseInterface{    try {        $event = OpenEmail::verifyWebhookSignature(            (string) $request->getBody(),            $request,            (string) getenv('OPENEMAIL_WEBHOOK_SECRET'),        );    } catch (WebhookSignatureException) {        return $responses->createResponse(400);    }     error_log($event['type'] . ' ' . $event['id']);     return $responses->createResponse(204);}

Responda depressa: uma entrega que não obtenha resposta em 5 segundos conta como falhada e é enviada de novo mais tarde, por isso passe o trabalho a uma fila e responda com um 2xx. Os controladores de Laravel e de Symfony, com a passagem para a fila, estão na página de frameworks.

OpenEmail\Constants\WebhookSignatureHeaders nomeia os três cabeçalhos que uma entrega traz: X-OpenEmail-Signature, X-OpenEmail-Event com o tipo do evento, e X-OpenEmail-Delivery com o seu id, o mesmo id que está no corpo. Esse id mantém-se igual em todas as repetições e reenvios do evento, por isso é esse que deve guardar quando ignora eventos que já tratou.

Durante a rotação do segredo

webhooks->rotateSecret não tem janela de sobreposição: as entregas são assinadas com o novo segredo a partir do momento em que a chamada retorna. Primeiro faça o deploy de um recetor que aceite qualquer um dos segredos, depois rode, guarde o novo segredo onde o recetor o lê e, por fim, abandone o antigo.

two_secrets.php
use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail; function verifyDelivery(string $payload, mixed $headers): array{    $next = getenv('OPENEMAIL_WEBHOOK_SECRET_NEXT');     try {        return OpenEmail::verifyWebhookSignature($payload, $headers, (string) getenv('OPENEMAIL_WEBHOOK_SECRET'));    } catch (WebhookSignatureException $error) {        if (!is_string($next) || $next === '') {            throw $error;        }         return OpenEmail::verifyWebhookSignature($payload, $headers, $next);    }}

webhooks->test envia um evento sintético assinado, por isso chame-o depois da rotação para provar que o novo segredo é verificado antes de remover o antigo.