Skip to content

How-to : Système de livraison de webhooks

Référence FT : FT308 (NENE2-FT/webhookdeliverylog) — Système de livraison de webhooks : protection SSRF via UrlValidator (HTTPS uniquement, liste de blocage d'IP privées, prévention d'injection CRLF), signature HMAC-SHA256 avec liaison temporelle, secret stocké comme hash SHA-256 (jamais en clair), secret non retourné dans les réponses GET, les endpoints désactivés ignorent la livraison, isolation par type d'événement, ATK-01~12 tous BLOCKED, 31 tests / 47 assertions PASS.

Ce guide montre comment construire un système de livraison de webhooks où les secrets de webhook sont protégés, les URLs sont validées contre les attaques SSRF, et les payloads sont signés avec des timestamps pour prévenir les attaques de rejeu.

Schéma

sql
CREATE TABLE IF NOT EXISTS webhook_endpoints (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    url         TEXT    NOT NULL,
    event_type  TEXT    NOT NULL,
    secret_hash TEXT    NOT NULL,   -- hash SHA-256 du secret brut
    max_retries INTEGER NOT NULL DEFAULT 3,
    active      INTEGER NOT NULL DEFAULT 1,
    created_at  TEXT    NOT NULL
);

CREATE TABLE IF NOT EXISTS webhook_deliveries (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    endpoint_id   INTEGER NOT NULL REFERENCES webhook_endpoints(id),
    event_type    TEXT    NOT NULL,
    payload       TEXT    NOT NULL DEFAULT '{}',
    status        TEXT    NOT NULL DEFAULT 'pending',
    attempt_count INTEGER NOT NULL DEFAULT 0,
    last_status   INTEGER,
    last_error    TEXT,
    delivered_at  TEXT,
    created_at    TEXT    NOT NULL,
    updated_at    TEXT    NOT NULL
);

secret_hash stocke le hash SHA-256 du secret brut — jamais le secret lui-même. Le flag active permet de désactiver doucement un endpoint sans supprimer l'historique de livraison.

Protection SSRF — UrlValidator

php
final class UrlValidator
{
    public function validate(string $url): ?string
    {
        // Bloquer l'injection CRLF et les octets null
        if (str_contains($url, "\n") || str_contains($url, "\r") || str_contains($url, "\0")) {
            return 'URL contains illegal control characters.';
        }

        $parsed = parse_url($url);
        if ($parsed === false || !isset($parsed['scheme'], $parsed['host'])) {
            return 'URL is not valid.';
        }

        // HTTPS uniquement
        if (strtolower($parsed['scheme']) !== 'https') {
            return 'Only HTTPS URLs are allowed for webhook delivery.';
        }

        $host = strtolower($parsed['host']);

        // Bloquer localhost et variantes
        if (in_array($host, ['localhost', 'ip6-localhost', 'ip6-loopback'], true)) {
            return "Webhook URL must not target '{$host}'.";
        }

        // Bloquer les TLD internes
        foreach (['.local', '.internal', '.test', '.example', '.invalid', '.localhost'] as $pattern) {
            if (str_ends_with($host, $pattern)) {
                return "Webhook URL must not target '{$pattern}' domains.";
            }
        }

        // Bloquer les plages IPv4 privées (127.x, 10.x, 172.16-31.x, 192.168.x)
        $ip = trim($host, '[]');
        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
            if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
                return 'Webhook URL must not target private or loopback IP addresses.';
            }
        }

        // Bloquer les IPv6 privées (::1, fc00::/7, fe80::/10)
        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
            // ... vérifications de plages privées IPv6
        }

        return null; // valide
    }
}

La validation bloque :

  1. Injection CRLF/octet null — empêche l'injection d'en-têtes dans les requêtes HTTP vers l'URL du webhook
  2. Schémas non-HTTPShttp://, file://, ftp://, gopher:// tous bloqués
  3. Adresses loopback127.0.0.0/8, ::1
  4. Plages privées10.x, 172.16-31.x, 192.168.x, 0.0.0.0
  5. TLD internes.local, .internal, .test, .example

Signature de webhook — HMAC-SHA256 + Timestamp

php
final class WebhookSigner
{
    public function sign(string $rawSecret, string $body, string $timestamp): string
    {
        $payload = $timestamp . '.' . $body;  // le timestamp lie la signature au temps
        $mac     = hash_hmac('sha256', $payload, $rawSecret);
        return 'sha256=' . $mac;
    }

    public function hashSecret(string $rawSecret): string
    {
        return hash('sha256', $rawSecret);
    }
}

Le format de signature sha256=<hex> est le même pattern utilisé par les webhooks GitHub. Le timestamp est inclus dans le contenu signé (timestamp.body) — cela prévient les attaques de rejeu : une signature capturée au temps T ne peut pas être rejouée au temps T+1h.

Stockage du secret — Hash, jamais en clair

php
// À la création de l'endpoint :
$secretHash = $this->signer->hashSecret($rawSecret);
$this->repo->createEndpoint($url, $eventType, $secretHash, $maxRetries);

// Retourner le secret brut UNE FOIS à l'appelant :
return $this->json->create([
    'id'     => $endpointId,
    'secret' => $rawSecret,  // affiché uniquement à la création
    // stocké comme : secret_hash = SHA-256($rawSecret)
]);

Le secret brut est retourné à l'appelant une seule fois au moment de la création. Les réponses GET /endpoints/{id} ultérieures n'incluent jamais secret ni secret_hash.

php
// Réponse GET endpoint — secret NON inclus
return $this->json->create([
    'id'         => (int) $endpoint['id'],
    'url'        => $endpoint['url'],
    'event_type' => $endpoint['event_type'],
    'active'     => (bool) $endpoint['active'],
    'max_retries'=> (int) $endpoint['max_retries'],
    'created_at' => $endpoint['created_at'],
    // 'secret_hash' intentionnellement omis
]);

Saut d'endpoint désactivé

php
// Handler de dispatch
if (!(bool) $endpoint['active']) {
    return $this->json->create(['message' => 'Endpoint is inactive, no delivery queued.'], 200);
}

Les endpoints désactivés ne reçoivent aucune nouvelle livraison. Cela permet de désactiver un webhook sans supprimer l'endpoint ni son historique de livraison.

Isolation par type d'événement

Chaque endpoint s'abonne à un event_type spécifique. Lors du dispatch :

php
$endpoints = $this->repo->findActiveEndpointsByType($eventType);
// Seuls les endpoints correspondant à l'event_type reçoivent la livraison

Un endpoint abonné à order.created ne reçoit pas les événements order.cancelled.


ATK Assessment — Cracker-Mindset Attack Test

ATK-01 — SSRF via IPv4 loopback (127.x.x.x) 🚫 BLOCKED

Attack : Enregistrer un endpoint avec url: "https://127.0.0.1/admin". Result : BLOCKED — UrlValidator détecte la plage IPv4 privée → 422.


ATK-02 — SSRF via 0.0.0.0 🚫 BLOCKED

Attack : url: "https://0.0.0.0/internal". Result : BLOCKED — plage IP réservée bloquée par FILTER_FLAG_NO_RES_RANGE → 422.


ATK-03 — SSRF via plage privée 10.x.x.x 🚫 BLOCKED

Attack : url: "https://10.0.0.1/internal". Result : BLOCKED — plage IPv4 privée → 422.


ATK-04 — SSRF via plage privée 172.16-31.x.x 🚫 BLOCKED

Attack : url: "https://172.16.0.1/internal". Result : BLOCKED — plage IPv4 privée → 422.


ATK-05 — Dégradation du schéma HTTP 🚫 BLOCKED

Attack : url: "http://example.com/hook" (non-HTTPS). Result : BLOCKED — vérification du schéma : seul https autorisé → 422.


ATK-06 — Schéma file:// 🚫 BLOCKED

Attack : url: "file:///etc/passwd". Result : BLOCKED — la vérification du schéma bloque les non-HTTPS → 422.


ATK-07 — Injection CRLF dans l'URL 🚫 BLOCKED

Attack : url: "https://example.com/\r\nX-Injected: header". Result : BLOCKED — vérification str_contains($url, "\r") → 422.


ATK-08 — Octet null dans l'URL 🚫 BLOCKED

Attack : url: "https://example.com/\0hidden". Result : BLOCKED — vérification str_contains($url, "\0") → 422.


ATK-09 — Fuite du secret via GET endpoint 🚫 BLOCKED

Attack : GET /endpoints/{id} pour récupérer le secret stocké. Result : BLOCKED — La réponse GET omet entièrement les champs secret et secret_hash.


ATK-10 — Fuite du secret via la réponse de dispatch 🚫 BLOCKED

Attack : Inspecter le corps de la réponse de dispatch pour trouver des données secrètes. Result : BLOCKED — La réponse de dispatch contient uniquement les métadonnées de livraison, aucun champ secret.


ATK-11 — Attaque de rejeu (signature capturée) 🚫 BLOCKED

Attack : Capturer un webhook signé et le rejouer avec la même signature plus tard. Result : BLOCKED — La signature est HMAC(timestamp.body, secret). Le timestamp change à chaque livraison ; l'ancienne signature ne correspond pas au nouveau timestamp.


ATK-12 — Signature forgée avec un mauvais secret 🚫 BLOCKED

Attack : Calculer un HMAC avec un secret deviné/différent, le soumettre comme signature valide. Result : BLOCKED — Le destinataire valide avec le hash de secret stocké ; le HMAC forgé ne correspond pas.


Résumé ATK

IDAttaqueRésultat
ATK-01SSRF loopback IPv4🚫 BLOCKED
ATK-02SSRF 0.0.0.0🚫 BLOCKED
ATK-03SSRF privé 10.x🚫 BLOCKED
ATK-04SSRF privé 172.16-31.x🚫 BLOCKED
ATK-05Dégradation du schéma HTTP🚫 BLOCKED
ATK-06Schéma file://🚫 BLOCKED
ATK-07Injection CRLF dans l'URL🚫 BLOCKED
ATK-08Octet null dans l'URL🚫 BLOCKED
ATK-09Fuite du secret via GET🚫 BLOCKED
ATK-10Fuite du secret via dispatch🚫 BLOCKED
ATK-11Attaque de rejeu🚫 BLOCKED
ATK-12Signature forgée🚫 BLOCKED

12 BLOCKED, 0 EXPOSED UrlValidator bloque tous les vecteurs SSRF. Le HMAC lié au timestamp prévient les rejeux. Secret stocké comme hash, jamais retourné après la création.


Ce qu'il ne faut PAS faire

Anti-patternRisque
Stocker le secret brut du webhook en DBUne violation de la DB expose tous les secrets ; le hash SHA-256 est à sens unique
Retourner le secret dans la réponse GETToute fuite d'API admin expose tous les secrets de webhook
HMAC sur le corps uniquement (sans timestamp)Attaque de rejeu : signature capturée réutilisée indéfiniment
Autoriser les URLs de webhook http://Écoute du trafic sur les payloads de webhook
Pas de validation SSRF sur l'URLLe système de webhook utilisé pour sonder le réseau interne
Autoriser 127.x, 10.x dans l'URL de webhookLe serveur fait des requêtes vers ses propres services internes
Pas de vérification CRLFURL avec \r\n injecte des en-têtes dans la requête HTTP sortante
Livrer aux endpoints inactifsLes endpoints désactivés continuent à recevoir du trafic
Pas de filtrage par type d'événementTous les types d'événements livrés à tous les endpoints

Publié sous licence MIT.