Aller à la documentation
PHP

Vérifier une livraison

`OpenEmail::verifyWebhookSignature` : à temps constant, avec une fenêtre de rejeu, et l'événement analysé en retour.

Dans un gestionnaire de requêtes

Une URL de webhook est publique. N'importe quoi sur Internet peut y envoyer en POST du JSON de la bonne forme : un gestionnaire qui lit le type de l'événement sans vérifier la signature est donc une API d'écriture ouverte.

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);

Passez le corps BRUT, sous forme de chaîne. Le décoder puis le réencoder change l'ordre des clés et les espaces, et la signature ne correspondra plus : c'est pourquoi la charge utile est typée string. Un tableau, comme le résultat de json_decode() ou l'entrée déjà analysée par un framework, donne une TypeError au lieu d'être vérifié. Les en-têtes peuvent être un tableau avec le nom de l'en-tête dans n'importe quelle casse, tel que le renvoie getallheaders(), $_SERVER, où l'en-tête arrive sous la forme HTTP_X_OPENEMAIL_SIGNATURE, tout itérable comme un HeaderBag Symfony ou Laravel, ou une requête PSR-7. Une valeur qui est un tableau est lue à partir de son premier élément.

Deux points dont cette vérification s'occupe et qu'un contrôle écrit à la main néglige d'ordinaire. Elle compare le MAC en temps constant avec hash_equals(), si bien qu'on ne peut pas en retrouver le préfixe correct en mesurant le temps de réponse. Elle rejette aussi une livraison décalée de plus de toleranceSeconds: dans un sens comme dans l'autre, cinq minutes sauf indication contraire, pour qu'une requête capturée ne puisse pas être rejouée indéfiniment. toleranceSeconds: 0 désactive la vérification de rejeu. Les deux bugs sont silencieux : un gestionnaire qui a l'un ou l'autre passe tous les tests que vous penseriez à écrire.

Elle lève OpenEmail\Exception\WebhookSignatureException à chaque échec : en-tête X-OpenEmail-Signature absent, en-tête qui ne respecte pas la forme t=<seconds>,v1=<hex>, horodatage hors de la fenêtre, signature qui ne correspond pas, ou corps signé qui n'est pas un objet JSON. En cas de succès, elle renvoie le corps décodé en un tableau à clés en camelCase, avec id, type, createdAt et data : il n'y a donc pas de second json_decode() à rater.

Un secret vide lève plutôt OpenEmail\Exception\InvalidArgumentException, car c'est une erreur dans votre configuration et non une mauvaise livraison, et le (string) placé devant getenv() transforme une variable non définie exactement en ce cas. N'interceptez que WebhookSignatureException et répondez 400. Un secret manquant termine alors le script par une exception non interceptée, à laquelle PHP en production répond par un 500, et la livraison est retentée une fois que vous l'avez corrigé, au lieu que chaque événement soit rejeté comme falsifié.

La vérification repose sur hash_hmac() et hash_equals(), présentes dans toute build de PHP : elle n'a donc besoin d'aucune extension. L'exemple utilise return plutôt que d'appeler exit, si bien que les mêmes lignes fonctionnent aussi dans une fonction ou un contrôleur frontal.

Dans une application PSR-7

Avec une requête PSR-7, comme celle que vous fournissent Slim et Mezzio, passez la requête elle-même comme en-têtes et son corps sous forme de chaîne.

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);}

Répondez vite : une livraison qui ne reçoit pas de réponse en 5 secondes compte comme échouée et est renvoyée plus tard. Confiez donc le travail à une file d'attente et répondez par un 2xx. Les contrôleurs Laravel et Symfony, avec la remise à la file d'attente, se trouvent sur la page des frameworks.

OpenEmail\Constants\WebhookSignatureHeaders nomme les trois en-têtes que porte une livraison : X-OpenEmail-Signature, X-OpenEmail-Event avec le type de l'événement, et X-OpenEmail-Delivery avec son id, le même id que dans le corps. Cet id reste identique à chaque nouvelle tentative et à chaque rejeu de l'événement : c'est donc lui qu'il faut stocker pour ignorer les événements déjà traités.

Pendant la rotation du secret

webhooks->rotateSecret n'a aucune fenêtre de recouvrement : les livraisons sont signées avec le nouveau secret dès que l'appel revient. Déployez d'abord un récepteur qui accepte l'un ou l'autre secret, faites la rotation, stockez le nouveau secret là où le récepteur le lit, puis abandonnez l'ancien.

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 envoie un événement synthétique signé : appelez-le donc après la rotation pour prouver que le nouveau secret se vérifie avant de retirer l'ancien.