Blog ·
Une URL de webhook qui est le secret
Un webhook entrant peut être sa propre clé si l’URL porte ce poids. Le prix : 160 bits, une signature sur l’horloge et un corps coupé pendant sa lecture.
Par Provibr
Quelqu’un veut que son outil de supervision démarre l’un de nos flux d’automatisation. Cet outil sait envoyer du JSON à une URL, et rien d’autre : pas de signature, pas d’en-tête propre, pas de certificat client. C’est un boîtier dans une baie, configuré en 2019, et il n’apprendra plus rien.
Vous pouvez refuser ce client, ou accepter que pour certaines sources l’URL soit elle-même la clé. Nous avons accepté, puis passé la manche entière à vérifier que cette URL porte vraiment ce poids au lieu de se contenter d’être longue.
Trente-deux caractères, jamais dérivés de quoi que ce soit
L’identifiant de /api/hooks/flows/<slug> compte trente-deux caractères pris dans un alphabet de trente-deux symboles : 160 bits d’entropie brute. Il n’est dérivé ni du compte, ni du flux, ni du nom du point d’entrée, car un identifiant calculable à partir de ce qu’un client a déjà publié est calculable par quiconque lit sa page d’état.
L’alphabet est le base32 de Crockford : les chiffres et les minuscules moins i, l, o et u. Le même choix que pour nos codes d’activation d’agents, pour la même raison. Cette chaîne se recopie dans le panneau d’administration de quelqu’un d’autre, et aucune police ne rend l et 1 distinguables quand on est fatigué.
Le secret est chiffré, pas haché
Un point d’entrée peut en outre porter un secret partagé, sous forme de signature HMAC ou de jeton bearer. Les deux sont stockés chiffrés, et cela mérite une explication, car l’instinct bâti par vingt ans de conseils sur les mots de passe est ici exactement faux.
Un mot de passe se hache parce qu’il suffit de répondre s’il s’agit de la même chaîne. Un secret HMAC, non : vérifier signifie calculer la signature soi-même, et cela réclame le texte en clair. Il entre donc sous AES-256-GCM, avec une clé dérivée du secret de la plateforme et des données authentifiées qui nomment le point d’entrée auquel il appartient, si bien qu’un chiffré prélevé sur une ligne ne se vérifie pas comme le secret d’une autre. C’est la mécanique qui protège déjà les clés de paiement et les secrets des scripts ; un second mécanisme voudrait dire une seconde histoire de rotation et un second endroit où se tromper.
Si cette source de clé change un jour, le chiffré ne s’ouvre pas et la requête est refusée au lieu d’être acceptée ; l’écran indique alors qu’il faut régénérer le secret. Échouer en ouvert devant un secret illisible est le genre de ligne qu’on écrit une fois dans l’urgence et qu’on retrouve trois ans plus tard.
Le client voit le secret une seule fois, à la création. Non parce que nous ne pourrions pas le montrer à nouveau, mais parce que « noté désormais à deux endroits » et « visible pour quiconque a les droits d’automatisation » sont deux risques distincts, et que seul le premier est inévitable.
L’horloge entre dans la matière signée
La signature est sha256=<hex> dans un en-tête, calculée sur l’horodatage, un point, puis le corps brut. L’horodatage voyage dans son propre en-tête et doit tomber à moins de cinq minutes de notre horloge, des deux côtés.
Mettre l’horodatage dans la matière signée plutôt qu’à côté, c’est tout le dispositif contre le rejeu. Si l’on ne signe que le corps, quiconque a capté une requête valide peut la rejouer éternellement avec un en-tête d’horodatage frais, et la fenêtre devient un ornement. Les deux bords comptent aussi : un contrôle qui ne refuse que le trop ancien accepte une signature datée de 2099.
La comparaison se fait en temps constant, car une égalité ordinaire sur du texte hexadécimal s’arrête au premier caractère différent, et ce temps suffit à deviner une signature octet par octet. Et l’horodatage n’est jugé qu’après validation de la signature, jamais avant. Dans l’autre sens, « expiré » serait un oracle gratuit : quelqu’un sans aucun secret valide apprendrait que le problème venait de son horloge et non de sa signature. La distinction entre signature fausse et signature expirée existe bel et bien, mais elle existe dans le reçu que lit le propriétaire du point d’entrée, pas dans la réponse faite à l’appelant.
Un corps coupé pendant qu’il arrive
Le plafond du corps vaut 64 Kio par défaut, et ce qui compte n’est pas le nombre mais l’endroit où il s’applique.
Lire la requête entière dans une chaîne puis en contrôler la longueur, c’est un contrôle postérieur au dommage : un corps de 500 Mo nous coûte 500 Mo avant le refus, et le plafond devient une invitation. Le corps est donc lu par morceaux depuis le flux, le total courant est comparé à chaque morceau, et le lecteur est annulé dès qu’il dépasse. Les octets enregistrés sont ceux que nous avons réellement lus, pas la Content-Length annoncée par l’appelant, car un appelant qui nous attaque est précisément celui dont il ne faut pas croire l’en-tête.
Le frein de débit se place après le corps et non avant, à l’inverse du conseil habituel, et il le faut : la signature se calcule sur le corps, donc un frein qui répond plus tôt ne peut pas savoir si la requête était légitime. Il compte par point d’entrée et non par adresse source. Une vraie source de webhooks a une adresse ; celui qui attaque en a mille, si bien qu’un frein posé sur l’adresse protège précisément la mauvaise partie.
Neuf refus, douze issues, et nulle part un corps
L’ordre des refus est la conception. D’abord la forme de l’identifiant, avant de toucher la base, car where slug = <un mégaoctet de texte> est un travail qu’un inconnu ne devrait pas pouvoir nous imposer. Ensuite l’existence, la révocation, la désactivation, la liste d’adresses, le plafond du corps, le frein de débit, la signature, et enfin la question de savoir si le JSON est seulement un objet.
Chaque refus à partir de la troisième étape écrit un reçu. Cette table est la réponse à « mon webhook ne fait rien », une phrase qui cache deux problèmes très différents : rien n’arrive, ou quelque chose arrive et nous le rejetons. Sans ligne, les deux sont indiscernables, et le client passe son après-midi sur le mauvais.
Le reçu porte l’issue comme mot propre à côté du statut HTTP, car les codes de statut sont une mauvaise langue pour cela : un même 403 couvre « votre adresse n’est pas sur la liste » et « ce point d’entrée est éteint », et cela envoie une personne vers deux écrans différents. Douze issues, en ajout seul, avec l’adresse source et les octets lus.
Le reçu ne porte jamais le corps : c’est la charge de quelqu’un d’autre, elle peut contenir n’importe quoi, et un confort de débogage ne vaut pas une table qui accumule en silence les données d’autrui. Les reçus sont gardés quatre-vingt-dix jours, délibérément moins que les exécutions auxquelles ils appartiennent, car une adresse source est une donnée personnelle d’un tiers qui n’a rien accepté.
Un refus n’écrit rien : un identifiant inconnu. Il n’y a pas de compte auquel rattacher la ligne, et une table qui grandit dès qu’un inconnu devine est une façon gratuite de remplir notre base.
Pourquoi 410 et 404 ne sont pas la même réponse
Révoquer un point d’entrée ne le supprime pas. La ligne reste, l’identifiant reste pris pour toujours, et une requête reçoit 410 au lieu de 404. Le nom redevient disponible, car distinguer deux points d’entrée vivants était tout l’objet de cette unicité, mais l’URL ne revient jamais.
L’alternative tentante consiste à répondre 404 dans les deux cas, pour que personne n’apprenne quels identifiants ont existé. Deviner un identifiant représente 160 bits de travail : celui qui en tient un l’a donc reçu, par une ligne de journal, une vieille intégration ou une capture d’écran, et il sait depuis longtemps qu’il existait. La distinction ne lui apprend rien.
Elle apprend beaucoup à la moitié honnête du monde. Une source qui continue d’émettre vers l’ancienne URL après une rotation voit au 410 qu’il lui en faut une nouvelle ; devant un 404, elle conclut que quelqu’un a mal tapé le chemin, et une personne va alors chercher un défaut dans le chemin plutôt que dans la rotation.
Le premier déclencheur sans serveur
Tous les déclencheurs précédents s’exerçaient sur une machine existante : provisionnée, réinstallée, supprimée. Une exécution par webhook n’a ni machine, ni offre, ni adresse, ni intégration, ni agent, ni personne de connecté.
Cela paraît mineur et ce fut l’arête la plus vive de la manche. Le catalogue de variables note pour chaque groupe les déclencheurs auxquels il s’applique, et la constante signifiant « toujours » était littéralement la liste de tous les déclencheurs. Une cinquième valeur aurait donc offert à un flux par webhook toutes les variables de serveur, d’offre, d’adresse, de réseau et d’agent dans le sélecteur ; le validateur les aurait approuvées, et la première vraie requête serait tombée au rendu, dans une exécution que personne ne regarde.
Il existe donc désormais une liste séparée qui répond à une autre question : ce déclencheur s’exerce-t-il sur un serveur ? Un sixième déclencheur devra y répondre d’abord. Ce qui manque à une exécution par webhook est retiré du contexte plutôt que laissé présent et vide, et un nœud d’action dans un tel graphe est refusé à l’enregistrement.
Le corps atterrit sur un seul niveau, en texte, nombre ou booléen. Les champs inconnus sont ignorés plutôt que refusés, et c’est le seul point où une charge de webhook diffère d’une charge écrite par le client lui-même : une charge écrite à la main avec une faute de frappe doit échouer, mais le message d’un tiers contient des valeurs nulles, des objets imbriqués et des listes parce qu’il n’a jamais été écrit pour nous, et refuser toute la requête pour un champ de trop rend inutilisable n’importe quelle source réelle.
Ce que nous noterions
- Si l’URL est la clé, il faut en faire une clé. Aléatoire, non dérivée, indevinable, révocable, jamais réémise.
- Un secret avec lequel on calcule ne se hache pas. Le chiffrer, le lier à son propriétaire, échouer en fermé quand il ne s’ouvre pas.
- Signer l’horloge, pas seulement le corps. Un horodatage hors de la signature est une protection qui ne protège rien.
- Appliquer le plafond de taille pendant que les octets arrivent. Un contrôle après lecture a déjà payé le prix.
- Freiner ce que le client a réglé, pas ce que l’attaquant contrôle.
- Journaliser les refus, pas la charge. Les refus rendent « il ne se passe rien » répondable ; la charge appartient à autrui.
- Si un cas nouveau n’a pas de sujet, chercher toute liste qui voulait dire « tous les cas ». L’une d’elles est un synonyme de l’énumération, et elle ne le dit pas.
Pour être clair : les points d’entrée, les schémas, les reçus et le déclencheur webhook sont construits et fonctionnent. Restent non prouvés le repli en mémoire du frein lors d’une vraie panne de cache, le comportement après un changement du secret de plateforme, et tout cela sur le domaine propre d’un client.
Issu du travail de plateforme sur Provibr. Comment le graphe derrière le déclencheur reste fini est expliqué dans un flux est un graphe, et un graphe doit être fini, et comment le canevas a obtenu son fil dans un fil que l’on tire, et exactement un déclencheur.