Aller au contenu

Comment sécuriser les webhooks entrants

Un webhook entrant est une requête HTTP (Hypertext Transfer Protocol) reçue parce qu’un événement s’est produit chez un émetteur externe. Le point critique n’est pas de parser son JSON (JavaScript Object Notation), mais de prouver son origine, d’empêcher sa réutilisation et d’accepter ses doublons sans créer deux fois le même effet métier.

→ Lire l’explication complète de la marmotte

La recommandation est nette : accepter un webhook uniquement après vérification d’une signature HMAC (Hash-based Message Authentication Code) calculée avec un secret partagé distinct par émetteur et par environnement. Une adresse IP source, un en-tête User-Agent ou un nom de domaine résolu ne constituent pas une authentification. Ce sont des indices exploitables pour filtrer, pas une preuve cryptographique. Les slides qui vendent une allowlist IP comme une frontière de confiance oublient généralement les relais, les plages cloud mouvantes et le fait qu’une IP n’a jamais signé un message.

L’émetteur calcule typiquement HMAC-SHA-256, où SHA-256 signifie Secure Hash Algorithm sur 256 bits, sur le corps exact de la requête. Le récepteur recalcule la valeur avec le même secret, puis compare les deux signatures en temps constant. Une comparaison ordinaire qui s’arrête au premier caractère différent peut exposer une information temporelle exploitable, surtout sur un endpoint atteignable sans limitation de débit.

La vérification doit porter sur les octets bruts reçus avant toute désérialisation, normalisation ou réencodage. Parser puis re-sérialiser un document JSON modifie potentiellement les espaces, l’ordre des propriétés, les échappements Unicode ou la représentation des nombres. La signature devient invalide alors que le contenu paraît identique. Pire, signer une version reconstruite ouvre la porte à une confusion entre ce qui est vérifié et ce qui est traité.

Le schéma de signature doit être explicitement fixé côté récepteur : algorithme, format de l’en-tête, encodage hexadécimal ou Base64, et chaîne couverte par le calcul. Ne jamais accepter l’algorithme annoncé par l’appelant. Cette erreur, très créative dans sa banalité, permet de transformer une politique cryptographique en menu à la carte pour attaquant pressé.

Le secret HMAC doit comporter au moins 256 bits générés aléatoirement. Un mot de passe applicatif ou une valeur issue d’un fichier .env copié depuis un wiki n’est pas un secret de signature, même si le fichier porte un cadenas dans l’outil de ticketing. Stocker cette valeur dans un gestionnaire de secrets, limiter sa lecture au processus recevant les webhooks et éviter toute journalisation des en-têtes de signature.

Pour les principes cryptographiques sous-jacents, consulter l’authentification des personnes et des services et les fondations événementielles. Le webhook reste une entrée Internet, pas une exception diplomatique au modèle de menace.

Une signature valide prouve qu’un message a été construit par une entité détenant le secret. Elle ne prouve pas qu’il est récent ni qu’il n’a pas déjà été accepté. Un attaquant qui intercepte une requête valide peut la renvoyer autant de fois que l’endpoint l’accepte. Changer le montant d’un paiement exige parfois une compromission ; rejouer une opération légitime suffit souvent à créer le désordre.

La meilleure option dépend du protocole proposé par l’émetteur :

  • Si l’émetteur signe un horodatage, rejeter toute requête dont l’écart avec l’horloge locale dépasse une fenêtre définie, généralement cinq minutes.
  • Si l’émetteur fournit un identifiant d’événement unique, conserver cet identifiant avec une durée de rétention supérieure à la fenêtre de rejeu et refuser les répétitions.
  • Si le protocole signe un nonce, c’est-à-dire une valeur aléatoire utilisable une seule fois, mémoriser chaque nonce accepté pendant la fenêtre autorisée.
  • Si l’émetteur ne signe ni horodatage, ni identifiant, ni nonce, la signature HMAC protège l’intégrité et l’origine, mais pas le rejeu. Il faut documenter cette limite et compenser par une idempotence métier stricte.

Un horodatage seul impose une synchronisation d’horloge fiable. Utiliser NTP (Network Time Protocol) avec plusieurs sources surveillées, alerter sur une dérive significative et traiter les décalages comme une panne de sécurité. Une fenêtre de cinq minutes avec une horloge décalée de huit minutes transforme tous les événements légitimes en incident applicatif. L’univers a déjà suffisamment de raisons de produire des alertes rouges.

Ne pas utiliser l’horodatage de réception comme donnée anti-rejeu : il est créé par le récepteur et ne lie rien cryptographiquement à l’émetteur. Vérifier d’abord la signature, puis la fraîcheur. Inverser cet ordre permet à un attaquant de provoquer des traitements inutiles, voire de sonder les règles de rejet sans posséder le secret.

La table des nonces ou identifiants consommés doit être atomique : une insertion conditionnelle, une contrainte d’unicité en base de données ou une opération SET NX avec expiration dans un magasin de clés. Un test suivi d’une insertion non atomique laisse passer deux requêtes concurrentes. Les courses critiques ne disparaissent pas parce qu’un diagramme porte le mot « event-driven ».

Les fournisseurs renvoient fréquemment un webhook lorsqu’ils n’obtiennent pas une réponse de succès dans leur délai. Cela inclut les cas où le traitement a réussi mais où la réponse HTTP a été perdue. Le doublon est donc normal, pas une anomalie à traiter par une prière au proxy.

Un traitement idempotent produit le même état final lorsqu’un même événement est soumis plusieurs fois. L’objectif n’est pas seulement de répondre 200 OK au deuxième appel : il faut empêcher la création de ressources, d’écritures comptables, de courriels ou de commandes répétées.

Conserver l’identifiant d’événement fourni par l’émetteur dans une table dédiée est le mécanisme de base. Cette table doit inclure l’émetteur, l’identifiant, l’état de traitement, l’horodatage de réception, l’empreinte du corps brut et le résultat terminal. Imposer une contrainte d’unicité sur (emetteur, event_id). Lorsqu’un doublon arrive, retourner un succès sans relancer l’effet métier si l’événement est déjà terminé.

L’identifiant externe ne suffit pas toujours. Certains protocoles réutilisent un identifiant pour représenter plusieurs versions d’un même objet. Dans ce cas, dédupliquer sur l’identifiant d’événement, puis appliquer un contrôle de version ou d’ordre sur l’objet métier. Un webhook commande.modifiee reçu après commande.annulee ne doit pas ressusciter une commande parce que le réseau a décidé de pratiquer la nécromancie distribuée.

Une transaction unique doit enregistrer l’événement et préparer l’effet à produire. Pour les effets asynchrones, utiliser un pattern d’outbox transactionnelle : écrire l’intention d’émission dans la même transaction que l’état métier, puis la publier séparément avec déduplication côté consommateur. Cette séparation est détaillée dans les architectures événementielles.

L’idempotence ne garantit pas l’ordre global des événements, l’exactly-once ou l’absence de messages perdus chez l’émetteur. Elle protège contre la répétition d’un événement déjà reçu. L’ordre exige une version signée ou une séquence fiable ; la perte exige réconciliation périodique avec l’API de l’émetteur ; l’exactly-once reste un slogan coûteux dès qu’une base de données et un réseau participent à la fête.

L’exemple suivant utilise uniquement la bibliothèque standard Python. La fonction reçoit le corps brut, vérifie un en-tête contenant un horodatage et une signature, puis rejette les requêtes hors fenêtre avant tout traitement JSON.

import hashlib
import hmac
import time
# Secret binaire chargé depuis un gestionnaire de secrets, jamais depuis le dépôt.
WEBHOOK_SECRET = bytes.fromhex(
"a4d0e1f44dc4f7ca4d94929e8610cc2f"
"4564b1cd23fc24751d4f10c21b6d9e3f2"
)
def verifier_webhook(raw_body: bytes, signature_header: str) -> bool:
"""
Format attendu : t=1700000000,v1=<signature_hex>
Le corps est utilisé tel qu'il a été lu sur le socket HTTP.
"""
elements = dict(item.split("=", 1) for item in signature_header.split(","))
timestamp = int(elements["t"])
received_signature = elements["v1"]
# Rejeter les messages trop anciens ou trop éloignés dans le futur.
if abs(time.time() - timestamp) > 300:
return False
# Le séparateur et l'ordre font partie du protocole signé.
signed_payload = str(timestamp).encode("ascii") + b"." + raw_body
expected_signature = hmac.new(
WEBHOOK_SECRET,
signed_payload,
hashlib.sha256,
).hexdigest()
# Comparaison en temps constant.
return hmac.compare_digest(expected_signature, received_signature)

Cette fonction ne doit jamais recevoir un corps déjà décodé puis réencodé. Dans de nombreux frameworks, il faut lire le flux brut avant que le middleware JSON ne le consomme. Conserver ce corps brut uniquement le temps nécessaire à la vérification et à l’empreinte d’audit. Journaliser le contenu complet d’un webhook expose souvent des données personnelles, des jetons ou des informations métier plus bavardes qu’un stagiaire le dernier jour.

Après True, extraire l’identifiant d’événement, effectuer l’insertion atomique de déduplication, puis traiter la charge utile. Répondre rapidement après l’enregistrement durable. Les traitements lents doivent partir vers un worker, sous peine de provoquer les retransmissions mêmes que le mécanisme tente de contrôler.

La terminaison TLS (Transport Layer Security) est obligatoire, mais TLS protège le transport entre deux points à un instant donné. Il ne remplace ni la signature applicative, ni l’anti-rejeu, ni l’idempotence. Une requête valide capturée après terminaison TLS reste valide si aucun mécanisme de fraîcheur ne l’encadre.

Limiter l’endpoint à POST, imposer une taille maximale de corps, un délai de lecture court et un débit par source. Vérifier le type de contenu seulement après la signature lorsque le protocole le couvre, mais rejeter les tailles absurdes avant de charger plusieurs gigaoctets en mémoire. L’authentification ne protège pas contre l’épuisement de ressources : un paquet de déchets peut être parfaitement non signé et parfaitement coûteux.

La rotation exige d’accepter temporairement au moins deux secrets identifiés par une version de clé, par exemple v1 et v2. Essayer tous les secrets sans identifiant rend la rotation ambiguë et multiplie le coût de vérification. Déployer d’abord l’acceptation de la nouvelle version, demander la bascule de l’émetteur, surveiller les signatures encore produites avec l’ancienne, puis la révoquer après un délai supérieur aux tentatives de retransmission maximales.

Superviser séparément les échecs de signature, les horodatages expirés, les doublons normaux, les erreurs de schéma et les échecs métier. Les agréger sous un unique compteur « webhooks invalides » revient à ranger un incendie, un cambriolage et une panne de courant dans le même ticket. Ne jamais inclure le secret, la signature complète ou le corps sensible dans l’alerte.

Sauvegarder les tables de déduplication et d’outbox selon la durée pendant laquelle un événement peut être rejoué ou réémis. Après restauration, une perte de ces enregistrements peut recréer des effets métier. La reprise doit donc inclure une réconciliation avec l’émetteur et, si nécessaire, un mode de traitement qui bloque les actions irréversibles jusqu’à validation.

Sans vérification de signature, l’URL du webhook devient le seul secret : elle circule dans la console de l’émetteur, les journaux de proxy, les tickets et les historiques de configuration, et toute personne qui la lit peut injecter un événement de changement d’état (paiement confirmé, compte validé, abonnement activé) que l’application exécute comme s’il venait de l’émetteur. L’audit perd alors toute valeur probante : un événement forgé et un événement authentique laissent la même trace, et l’enquête ne peut plus attribuer un effet métier à son origine réelle. Seule une comparaison ligne par ligne avec l’API de l’émetteur permet ensuite de trier le vrai du faux, sur toute la période où l’endpoint est resté ouvert.

Un webhook est un message envoyé par un service vers une application quand quelque chose se produit. L’application ne doit pas croire ce message sur parole. L’émetteur et le récepteur connaissent une même valeur secrète. L’émetteur calcule une signature à partir du contenu exact du message et de cette valeur. Le récepteur refait le calcul avec sa propre copie du secret. Si les deux résultats diffèrent, le message est rejeté avant d’être lu.

Le message doit aussi contenir une date récente, ou un identifiant utilisable une seule fois. Après vérification de la signature, l’application refuse les messages trop anciens et mémorise les identifiants déjà vus. Cela bloque une personne qui aurait récupéré un vrai message et essaierait de le renvoyer plus tard.

Un même message peut néanmoins arriver plusieurs fois parce que l’émetteur n’a pas reçu la réponse attendue. L’application enregistre donc son identifiant dans une base de données avec une règle interdisant les doublons. Le premier message déclenche le travail prévu. Les suivants reçoivent une réponse de réussite, mais ne recréent pas d’action.

Le secret doit être gardé hors du code source, changé périodiquement et accepté en deux versions pendant le changement. Les erreurs de signature, les messages expirés et les doublons doivent être surveillés séparément. Le chiffrement de la connexion ne remplace aucune de ces vérifications.

À lire aussi

Pages voisines

: les deux pages se citent l’une l’autre.