ENbext.dev
DocumentationFonctionnalités

Traduction automatique depuis l’anglais. Consulter l’original.

Sur cette page

Webhook

La fonctionnalité Webhook répond à une question : cette requête HTTP entrante provient-elle réellement du tiers dont elle prétend provenir ? Un WebhookPlugin est un vérificateur : remettez-lui la demande entrante brute, récupérez Ok(()) ou une erreur classifiée, et puis laissez votre itinéraire toucher la charge utile. Le trait est délibérément neutre en termes de fournisseur : le HMAC horodaté de Stripe, le corps HMAC de GitHub, le HMAC base64 de Shopify, le HMAC préfixé de Slack et l'URL + form-params HMAC de Twilio satisfont tous à la même surface, et un projet peut échanger les implémentations (ou en ajouter une sixième) en éditant bext.config.toml sans toucher au code de route.

Cette page couvre la surface du trait, pourquoi le trait est une vérification pure sans handle(event), comment les erreurs sont classées afin que le middleware choisisse le bon statut HTTP, ce que fait chacun des cinq vérificateurs de référence et comment câbler un vérificateur dans une route bext.

Le trait#

Chaque plugin de vérification de webhook implémente un trait, WebhookPlugin, défini dans bext-plugin-api::webhook :

rust
pub trait WebhookPlugin: Send + Sync {
    fn provider(&self) -> &str;
    fn scheme(&self) -> WebhookSchemeKind;
    fn verify(&self, req: &WebhookRequest) -> Result<(), WebhookError>;
    fn is_healthy(&self) -> bool { true }
}

Chaque méthode est synchrone (correspondant à tous les autres traits de bext-plugin-api), de sorte que la forme se déplace proprement dans les bacs à sable WASM, QuickJS et nsjail. Les cinq vérificateurs concernés sont de purs HMAC en mémoire : pas d'E/S réseau, rien à attendre. Les vérificateurs qui ont ensuite besoin d'un accès au réseau (un schéma de signature OAuth avec une récupération JWKS, par exemple) établissent un pont avec block_on de la même manière que SesMailerPlugin le fait aujourd'hui.

Vérification uniquement : non handle(event)#

L'esquisse du plan original dans plan/ecosystem/02-capabilities.md comprenait un async fn handle(&self, event: WebhookEvent) -> Result<HandlerOutcome, WebhookError> aux côtés de verify. Ce trait le supprime délibérément.

La raison est le principe d'architecture 6 (pas de champs spécifiques au fournisseur sur un trait). Un seul type WebhookEvent qui couvre « un Stripe charge.succeeded » et « une charge utile GitHub pull_request » et « un Shopify orders/create » ne peut pas exister sans être un blob typé en chaîne (auquel cas le trait ne fournit rien) ou sans glisser cinq décodeurs d'événements différents dans chaque caisse de vérificateur (auquel cas la capacité devient un engagement à durée indéterminée à désérialiser la charge utile de chacun). Ni l’un ni l’autre n’est acceptable.

Dans bext, cette œuvre a déjà une maison : la route. Un point de terminaison de webhook est une route normale ; le vérificateur s'exécute en tant que pré-gestionnaire, valide la signature et, si elle réussit, le corps de la route désérialise la charge utile JSON/formulaire en utilisant la forme qui intéresse réellement l'application. Cela permet de conserver une surface de trait minuscule et de placer la logique métier à sa place.

WebhookRequest#

Le vérificateur prend un instantané minimal et plat ABI de la demande entrante :

rust
pub struct WebhookRequest {
    pub url: String,
    pub method: String,
    pub headers: Vec<(String, String)>,
    pub body: Vec<u8>,
    pub received_at_ms: u64,
}

Plusieurs de ces choix comptent :

  • body: Vec<u8>, pas une valeur JSON analysée. Chaque schéma concerné signe le flux d'octets exact sur le câble. La désérialisation en serde_json::Value et la re-sérialisation modifieraient les espaces et l'ordre des clés et briseraient le HMAC. Le middleware transmet les octets bruts.
  • headers: Vec<(String, String)>, pas une carte. HTTP autorise les noms d’en-tête en double. Le stockage préservant les commandes maintient les journaux reproductibles et évite la perte de données. Le type dispose d'un assistant header(name) qui effectue une recherche de première correspondance sans tenir compte de la casse.
  • url est exactement la chaîne envoyée par le client. Le mécanisme de Twilio signe l’URL complète : ne la faites pas passer dans un analyseur d’URL pour la reformater ; les octets doivent être identiques.
  • received_at_ms est l’heure courante observée par l’hôte. Les vérificateurs qui imposent une fenêtre de validité, comme Stripe et Slack, utilisent cette valeur plutôt que de lire directement l’horloge système. Les tests restent ainsi déterministes et une seule horloge, gérée par le middleware, sert pour toute la requête.

WebhookSchemeKind#

Les extensions indiquent la famille qu’elles implémentent afin que l’administration affiche la configuration appropriée :

rust
pub enum WebhookSchemeKind {
    HmacBody,               // GitHub, Shopify
    HmacTimestampedBody,    // Stripe, Slack
    HmacUrlFormFields,      // Twilio
}

C’est une indication, pas une contrainte : de nouvelles variantes apparaissent avec l’ajout de fournisseurs.

Classification des erreurs et correspondance HTTP#

Chaque échec passe par une seule énumération. Le middleware choisit le code d’état selon sa variante :

rust
pub enum WebhookError {
    MissingHeader(String),
    MalformedPayload(String),
    InvalidSignature(String),
    ReplayDetected(String),
    Backend(String),
}
Variante État HTTP Réessayer ? Signification
MissingHeader 400 Non : corriger l’émetteur. Un en-tête de signature obligatoire manque dans la requête.
MalformedPayload 400 Non : corriger l’émetteur. Le format de l’en-tête est invalide : hexadécimal incorrect, segment t= manquant ou corps non UTF-8.
InvalidSignature 401 Non : secret incorrect ou altération. La comparaison HMAC a échoué. Impossible de la distinguer d’une falsification.
ReplayDetected 401 Non : renvoyer avec un nouvel horodatage. La signature est valide, mais l’horodatage sort de la fenêtre de validité.
Backend 500 Une seule fois, avec prudence. Erreur de l’hôte : secret de signature vide ou lecture de l’horloge impossible.

WebhookError::http_status() renvoie le code d’état recommandé pour chaque variante. Le middleware peut en choisir un autre : le trait ne fournit qu’une recommandation.

Les plugins de référence#

Vit à crates/bext-impls/bext-webhook-verifiers. Une caisse, cinq modules — stripe, github, shopify, slack, twilio — partageant un seul module hmac_helpers afin que la comparaison à temps constant ( Hmac::verify_slice, qui est à temps constant par construction) et la construction MAC vivent au même endroit. Dépendances : hmac + sha2 + sha1 + base64. Pas d'exécution asynchrone, pas de pile réseau, pas de tokio.

Rayure — StripeWebhookPlugin#

Spécification : https://stripe.com/docs/webhooks/signatures.

En-tête : Stripe-Signature: t=<unix-ts>,v1=<hex>[,v1=<hex>]. Plusieurs entrées v1 peuvent apparaître lors d'une rotation secrète ; le vérificateur accepte la demande si correspond à un. La charge utile signée est la chaîne d'octets {t}.{body} sous le secret de signature du webhook ( whsec_... dans le tableau de bord).

Fenêtre de relecture : 300 s par défaut, configurable via with_tolerance_secs. Le plugin compare received_at_ms (ou l'horloge système) au segment t= et renvoie ReplayDetected lorsque la dérive dépasse la tolérance.

rust
use bext_webhook_verifiers::StripeWebhookPlugin;

let verifier = StripeWebhookPlugin::new(b"whsec_abc...".to_vec())
    .with_tolerance_secs(300);

GitHub — GithubWebhookPlugin#

Spécification : https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries.

En-tête : X-Hub-Signature-256: sha256=<hex>. La charge utile signée est le corps brut sous le secret partagé du webhook. Pas d'horodatage sur le fil, donc pas de vérification de la fenêtre de relecture - les routes qui souhaitent une déduplication d'idempotence par rapport à l'en-tête X-GitHub-Delivery dans le gestionnaire.

Le vecteur de document publié ( secret = "It's a Secret to Everybody", body = "Hello, World!", signature sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17 ) est reproduit octet par octet dans la suite de tests de la caisse.

Shopify — ShopifyWebhookPlugin#

Spécification : https://shopify.dev/docs/apps/build/webhooks/subscribe/https#verify-a-webhook.

En-tête : X-Shopify-Hmac-Sha256: <base64>. La signature est la base64 de HMAC-SHA256(api_secret, raw_body). Pas de préfixe, pas d'horodatage. Shopify recommande la déduplication au niveau de l'application via X-Shopify-Webhook-Id.

Slack — SlackWebhookPlugin#

Spécification : https://api.slack.com/authentication/verifying-requests-from-slack.

En-têtes : X-Slack-Request-Timestamp: <unix-ts> plus X-Slack-Signature: v0=<hex>. La chaîne signée est v0:{ts}:{raw_body} sous le secret de signature de l'application. Slack recommande de rejeter les requêtes avec une dérive supérieure à 5 minutes, ce que le plugin fait par défaut.

Le vecteur Slack doc publié est reproduit dans la suite de tests : un corps de formulaire de commande slash, ts=1531420618, signature v0=a2114d57b48eac39b9ad189dd8316235a7b4a8d21a10bd27519666489c69b503, secret 8f742231b10e8888abcd99yyyzzz85a5.

Twilio — TwilioWebhookPlugin#

Spécification : https://www.twilio.com/docs/usage/webhooks/webhooks-security.

En-tête : X-Twilio-Signature: <base64>. Twilio utilise HMAC-SHA1 (le seul fournisseur concerné sur SHA-1) sous le jeton d'authentification du compte. La chaîne signée est :

  • l'URL complète de la requête (schéma + hôte + chemin + requête - bit identique à ce que le client a envoyé), suivie immédiatement de
  • pour les POST de formulaire : chaque champ de formulaire rendu sous la forme key||value avec les paires triées par ordre alphabétique par clé, concaténées sans séparateur entre les paires.
  • pour les POST sans formulaire (par exemple JSON) : rien — l'URL seule est signée.

Le vérificateur détecte les deux modes en regardant Content-Type : application/x-www-form-urlencoded sélectionne le chemin du formulaire, tout le reste (ou absent) sélectionne le chemin URL uniquement. Twilio ne comporte pas d'horodatage signé, il n'y a donc pas de vérification de la fenêtre de relecture.

Câblage d'un vérificateur dans une route#

Une utilisation typique est un middleware qui s'exécute avant le gestionnaire de corps. Pseudocode :

rust
use bext_plugin_api::webhook::{WebhookPlugin, WebhookRequest};
use bext_webhook_verifiers::StripeWebhookPlugin;

// At startup:
let stripe = StripeWebhookPlugin::new(std::env::var("STRIPE_SECRET").unwrap().into_bytes());

// Per request:
async fn stripe_handler(req: HttpRequest) -> HttpResponse {
    let wr = WebhookRequest {
        url: req.full_url(),
        method: req.method().to_string(),
        headers: req.headers_clone(),
        body: req.body_bytes().await,
        received_at_ms: now_ms(),
    };

    if let Err(e) = stripe.verify(&wr) {
        return HttpResponse::new(e.http_status()).body(e.to_string());
    }

    // Signature valid — now deserialize and act.
    let event: StripeEvent = serde_json::from_slice(&wr.body).unwrap();
    // ... business logic ...
    HttpResponse::ok()
}

La valeur de cette fonctionnalité n'est pas qu'elle masque le câblage de l'itinéraire, mais qu'elle centralise l'algorithme de vérification **, qui est subtil (comparaison en temps constant, fenêtre de relecture, gestion UTF-8, construction de chaînes de base) et facile à se tromper.

Configuration#

Chaque vérificateur lit son secret de signature depuis bext.config.toml :

toml
[webhooks.stripe]
plugin = "@bext/webhook-verifiers"
provider = "stripe"
secret = "{{env.STRIPE_WEBHOOK_SECRET}}"
tolerance_secs = 300

[webhooks.github]
plugin = "@bext/webhook-verifiers"
provider = "github"
secret = "{{env.GITHUB_WEBHOOK_SECRET}}"

[webhooks.slack]
plugin = "@bext/webhook-verifiers"
provider = "slack"
secret = "{{env.SLACK_SIGNING_SECRET}}"
tolerance_secs = 300

[webhooks.shopify]
plugin = "@bext/webhook-verifiers"
provider = "shopify"
secret = "{{env.SHOPIFY_API_SECRET}}"

[webhooks.twilio]
plugin = "@bext/webhook-verifiers"
provider = "twilio"
auth_token = "{{env.TWILIO_AUTH_TOKEN}}"

Les secrets ne résident jamais dans le fichier lui-même : la syntaxe {{env.FOO}} est résolue en variable d'environnement correspondante au moment du chargement.

Hors de portée#

  • Décodage d’événements. Les routes désérialisent les charges utiles dans la forme dont elles ont besoin. Le trait est uniquement un vérificateur.
  • Idempotence. Déduplication par rapport à l'en-tête delivery-id du fournisseur dans le gestionnaire d'itinéraire.
  • Formes de réponse. Chaque fournisseur a sa propre sémantique de nouvelle tentative et ses propres exigences « veuillez envoyer 200 » ; ceux-ci appartiennent à l'itinéraire, pas au vérificateur.
  • Rotation secrète. Stripe prend en charge plusieurs balises v1 sur le fil pour les fenêtres de rotation ; les autres vendeurs ne le font pas. Les plugins acceptent n’importe quelle balise correspondante ; la rotation multi-secret au niveau de la configuration est une amélioration future.

Le trait est un vérificateur. Tout est plus riche en couches au-dessus.

Attention

Le vérificateur prend le corps d'octet brut — n'analysez pas le JSON avant d'appeler verify. Chaque schéma HMAC signe les octets exacts sur le fil ; la désérialisation et la re-sérialisation, même une seule fois, invalideront la signature.

Conseil

Pour les prestataires de paiement, le decode_webhook_event de la capacité Paiement suppose que le corps a déjà été authentifié. Exécutez toujours d’abord le vérificateur de webhook, puis décodez.

En rapport#

Modifier cette page ↗Besoin d’aide ? ↗
Rechercher dans la documentation

Saisissez un sujet, une commande ou une question.