Sécurité 14 min de lecture

Webhooks entrants sécurisés: HMAC, anti-rejeu et rotation de secrets sans downtime

#laravel#securite#dette-technique

Implémentez une vérification HMAC robuste, une protection anti-rejeu et une rotation de secrets sans interruption pour vos webhooks entrants. Tutoriel concret avec pipeline Node.js/Express, Redis et Nginx.

Webhooks entrants sécurisés: HMAC, anti-rejeu et rotation de secrets sans downtime

Les webhooks sont un point d’entrée critique. Ce tutoriel montre, pas à pas, comment construire une terminaison webhook robuste avec vérification HMAC, protection anti-rejeu et rotation de secrets sans interruption, en s’appuyant sur Node.js/Express, Redis et Nginx. Vous obtiendrez un pipeline vérifiable, idempotent, observable et prêt pour la production.

Objectif

L’objectif est d’implémenter une chaîne complète de validation et de durcissement qui bloque la falsification, la relecture et la surcharge applicative, tout en permettant de faire tourner des secrets sans impacter la disponibilité. Nous allons créer un endpoint Express qui lit le corps brut, valide une signature HMAC-SHA256 construite à partir d’un timestamp signé et du corps, applique une fenêtre temporelle, déduplique via Redis pour neutraliser les replays, répond rapidement (202) et envoie le message validé vers une file asynchrone. La terminaison est protégée en amont par Nginx (limites de taille/débit, méthodes autorisées) et instrumentée par des métriques Prometheus et des journaux structurés.

Contexte et menaces: ce que vous devez bloquer

Un adversaire peut tenter de forger une requête (spoof) en imitant un fournisseur, rejouer un message capturé pour déclencher plusieurs traitements (replay), exploiter une désérialisation trop tôt pour pivoter dans l’application, ou saturer votre service avec des payloads volumineux (DoS applicatif). Une simple liste d’IP autorisées ne suffit pas, car les fournisseurs passent souvent par des CDN aux IP dynamiques et certains environnements cloud masquent la source réelle. L’IP peut compléter la décision (défense en profondeur), mais ne remplace jamais une signature vérifiable. Le but est d’aboutir à un point d’entrée dont chaque appel est authentifiable, idempotent, mesurable et dont les secrets se renouvellent sans downtime.

Pré-requis techniques et design

Le schéma retenu repose sur un HMAC-SHA256 calculé côté fournisseur et vérifié côté consommateur. Le fournisseur envoie un en-tête de signature (par exemple X-Signature) et un X-Timestamp signé qui entre dans le calcul, typiquement sous la forme canoniques “timestamp|body”. Pour éviter que des différences d’encodage ne fassent diverger la signature, le serveur doit capturer le corps brut du message tel qu’il a été reçu, avant toute transformation. Les secrets utilisés pour la signature sont injectés par variables d’environnement et idéalement chargés depuis un gestionnaire (Vault/SSM) avec versionning et rotation planifiée. Une horloge fiable est indispensable : synchronisez vos hôtes via NTP et acceptez une tolérance de déphasage raisonnable (±300 secondes est un choix courant).

Implémentation Node.js/Express: pipeline de validation

L’endpoint webhook doit lire le corps brut, vérifier la signature en temps constant et appliquer des contrôles basiques (méthode, Content-Type, taille). Le code ci-dessous montre une implémentation réaliste avec Express, Redis, Ajv (validation de schéma), prom-client (métriques) et un rate limit applicatif.

// server.js (CommonJS)
const express = require('express');
const crypto = require('crypto');
const { createClient } = require('redis');
const Ajv = require('ajv');
const rateLimit = require('express-rate-limit');
const promClient = require('prom-client');

// Configuration
const PORT = process.env.PORT || 8080;
const PROVIDER_ID = process.env.WEBHOOK_PROVIDER_ID || 'acme';
const ACTIVE_KEY_ID = process.env.WEBHOOK_ACTIVE_KEY_ID || 'K1';
const KEYS = {
  K1: process.env.WEBHOOK_SECRET_K1 || 'dev-secret-k1',
  K0: process.env.WEBHOOK_SECRET_K0 || '',
};
const WINDOW_SECONDS = parseInt(process.env.WEBHOOK_WINDOW_SECONDS || '300', 10); // ±300s
const MAX_BODY = process.env.WEBHOOK_MAX_BODY || '256kb';

// Redis
const redis = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379' });
redis.on('error', (e) => console.error('redis_error', e));
redis.connect();

// Prometheus
const registry = new promClient.Registry();
promClient.collectDefaultMetrics({ register: registry });
const validateCounter = new promClient.Counter({
  name: 'webhook_validate_total',
  help: 'Total validations de webhook par résultat',
  labelNames: ['result', 'provider', 'keyId'],
});
const validateLatency = new promClient.Histogram({
  name: 'webhook_validate_latency_seconds',
  help: 'Latence de validation des webhooks',
  buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2],
});
const observedSkew = new promClient.Gauge({
  name: 'webhook_observed_skew_seconds',
  help: 'Skew d’horloge observé',
  labelNames: ['provider'],
});
registry.registerMetric(validateCounter);
registry.registerMetric(validateLatency);
registry.registerMetric(observedSkew);

// App
const app = express();
app.set('trust proxy', true);

// Rate limit applicatif (complète celui de Nginx)
const limiter = rateLimit({
  windowMs: 60_000,
  max: 120,
  standardHeaders: true,
  legacyHeaders: false,
  keyGenerator: (req) => req.ip,
});
app.use('/webhooks/acme', limiter);

// Métriques
app.get('/metrics', async (req, res) => {
  res.set('Content-Type', registry.contentType);
  res.send(await registry.metrics());
});

// Schéma JSON d’exemple (à adapter au fournisseur)
const ajv = new Ajv({ allErrors: true, removeAdditional: true });
const eventSchema = {
  type: 'object',
  properties: {
    id: { type: 'string' },
    type: { type: 'string' },
    data: { type: 'object' },
  },
  required: ['id', 'type', 'data'],
  additionalProperties: false,
};
const validateEvent = ajv.compile(eventSchema);

// Helpers
function computeSignature(secret, timestamp, rawBuf) {
  const canonical = `${timestamp}|${rawBuf.toString('utf8')}`;
  return crypto.createHmac('sha256', secret).update(canonical).digest('hex');
}

function timingSafeEqualHex(aHex, bHex) {
  if (!/^[a-f0-9]{64}$/i.test(aHex) || !/^[a-f0-9]{64}$/i.test(bHex)) return false;
  const a = Buffer.from(aHex, 'hex');
  const b = Buffer.from(bHex, 'hex');
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

function pickSecrets(headers) {
  // Supporte la rotation: si x-key-id est fourni et connu => on l’utilise
  const keyId = String(headers['x-key-id'] || '').trim();
  if (keyId && KEYS[keyId]) return { ordered: [{ keyId, secret: KEYS[keyId] }], hinted: true };
  // Sinon on tente K1 (active) puis K0 (ancienne)
  const ordered = [];
  if (KEYS[ACTIVE_KEY_ID]) ordered.push({ keyId: ACTIVE_KEY_ID, secret: KEYS[ACTIVE_KEY_ID] });
  const fallback = ACTIVE_KEY_ID === 'K1' ? 'K0' : 'K1';
  if (KEYS[fallback]) ordered.push({ keyId: fallback, secret: KEYS[fallback] });
  return { ordered, hinted: false };
}

async function markReplay(providerId, signature, ts) {
  const bucket = Math.floor(Number(ts) / 60); // bucket par minute
  const key = `wh:replay:${providerId}:${bucket}:${signature}`;
  const ok = await redis.set(key, '1', { NX: true, EX: 600 });
  return ok === 'OK';
}

function logStructured(fields) {
  // Journaux JSON minimaux
  console.log(JSON.stringify({ ts: Date.now(), ...fields }));
}

// Middleware raw body sur ce seul endpoint
app.post(
  '/webhooks/acme',
  express.raw({ type: ['application/json', 'application/x-www-form-urlencoded'], limit: MAX_BODY }),
  async (req, res) => {
    const endTimer = validateLatency.startTimer();
    const ua = String(req.headers['user-agent'] || '');
    const ct = String(req.headers['content-type'] || '').toLowerCase();
    const method = req.method;
    const raw = req.body instanceof Buffer ? req.body : Buffer.from('');
    const sig = String(req.headers['x-signature'] || '').trim().toLowerCase();
    const tsHeader = String(req.headers['x-timestamp'] || '').trim();
    const nowSec = Math.floor(Date.now() / 1000);

    // Contrôles basiques
    if (method !== 'POST') {
      validateCounter.inc({ result: 'bad_method', provider: PROVIDER_ID, keyId: '' });
      logStructured({ providerId: PROVIDER_ID, result: 'bad_method', ua });
      return res.status(405).end();
    }
    if (!['application/json', 'application/x-www-form-urlencoded'].includes(ct)) {
      validateCounter.inc({ result: 'bad_ct', provider: PROVIDER_ID, keyId: '' });
      logStructured({ providerId: PROVIDER_ID, result: 'bad_ct', ct });
      return res.status(400).end();
    }
    if (!(raw && raw.length)) {
      validateCounter.inc({ result: 'empty_body', provider: PROVIDER_ID, keyId: '' });
      logStructured({ providerId: PROVIDER_ID, result: 'empty_body' });
      return res.status(400).end();
    }

    // Vérification HMAC + fenêtre temporelle + anti-rejeu
    const ts = Number(tsHeader);
    const skew = Math.abs(nowSec - ts);
    observedSkew.set({ provider: PROVIDER_ID }, nowSec - ts);
    if (!Number.isFinite(ts) || skew > WINDOW_SECONDS) {
      validateCounter.inc({ result: 'expired_ts', provider: PROVIDER_ID, keyId: '' });
      logStructured({ providerId: PROVIDER_ID, result: 'expired_ts', ts, nowSec, skew });
      // Réponse générique pour ne rien révéler
      endTimer();
      return res.status(401).end();
    }

    const { ordered } = pickSecrets(req.headers);
    let verified = false;
    let usedKeyId = '';
    for (const { keyId, secret } of ordered) {
      const expected = computeSignature(secret, ts, raw);
      if (timingSafeEqualHex(sig, expected)) {
        verified = true;
        usedKeyId = keyId;
        break;
      }
    }
    if (!verified) {
      validateCounter.inc({ result: 'bad_sig', provider: PROVIDER_ID, keyId: usedKeyId });
      logStructured({ providerId: PROVIDER_ID, result: 'bad_sig' });
      endTimer();
      return res.status(401).end();
    }

    // Anti-rejeu (sans parser le JSON)
    const firstSeen = await markReplay(PROVIDER_ID, sig, ts);
    if (!firstSeen) {
      validateCounter.inc({ result: 'replay', provider: PROVIDER_ID, keyId: usedKeyId });
      logStructured({ providerId: PROVIDER_ID, result: 'replay' });
      endTimer();
      // Réponse générique
      return res.status(401).end();
    }

    // Désérialisation après validation de signature
    let payload;
    try {
      if (ct === 'application/json') {
        payload = JSON.parse(raw.toString('utf8'));
        if (!validateEvent(payload)) {
          validateCounter.inc({ result: 'bad_schema', provider: PROVIDER_ID, keyId: usedKeyId });
          logStructured({ providerId: PROVIDER_ID, result: 'bad_schema', errors: validateEvent.errors });
          endTimer();
          return res.status(400).end();
        }
      } else {
        // application/x-www-form-urlencoded
        const qs = require('querystring');
        payload = qs.parse(raw.toString('utf8'));
      }
    } catch (e) {
      validateCounter.inc({ result: 'bad_json', provider: PROVIDER_ID, keyId: usedKeyId });
      logStructured({ providerId: PROVIDER_ID, result: 'bad_json' });
      endTimer();
      return res.status(400).end();
    }

    // Traitement asynchrone: push dans une file (Redis Streams)
    try {
      await redis.xAdd(`wh:${PROVIDER_ID}:events`, '*', {
        ts: String(ts),
        sig,
        body: raw.toString('utf8'),
      });
    } catch (e) {
      validateCounter.inc({ result: 'queue_err', provider: PROVIDER_ID, keyId: usedKeyId });
      logStructured({ providerId: PROVIDER_ID, result: 'queue_err', err: String(e) });
      // On peut renvoyer 500 pour réessai fournisseur
      endTimer();
      return res.status(500).end();
    }

    validateCounter.inc({ result: 'ok', provider: PROVIDER_ID, keyId: usedKeyId });
    logStructured({ providerId: PROVIDER_ID, result: 'ok', keyId: usedKeyId, ua });
    endTimer();
    // Réponse rapide
    return res.status(202).end();
  }
);

// Démarrage avec timeouts courts
const server = app.listen(PORT, () => {
  console.log(`listening_on:${PORT}`);
});
server.headersTimeout = 5000; // limite lecture des en-têtes
server.requestTimeout = 5000; // limite traitement

Ce serveur capture le corps brut, applique une fenêtre temporelle, compare la signature en temps constant, déduplique via Redis, valide le schéma JSON, pousse en file et répond rapidement. Les erreurs retournent des codes génériques (400/401/500) sans fuite d’information, tandis que les journaux structurés conservent des raisons codifiées (bad_sig, replay, expired_ts, etc.). La rotation de secrets est gérée par un modèle à deux clés et un en-tête facultatif x-key-id.

Protection anti-rejeu: timestamp et déduplication

La défense anti-rejeu commence par l’exigence d’un timestamp signé, rejeté s’il est en dehors d’une fenêtre prédéfinie. Dans l’exemple ci-dessus, la tolérance est de ±300 secondes et le déphasage mesuré est exposé comme métrique. Ensuite, la déduplication s’effectue sans désérialiser le payload, en utilisant la signature comme identifiant idempotent. Une clé de déduplication robuste peut combiner l’identifiant de fournisseur, un bucket temporel (par minute) et la signature ou un eventId si le fournisseur le garantit. Nous stockons cette clé dans Redis avec un TTL court (10 minutes) via SET NX EX, ce qui garantit que toute répétition de la même requête dans la fenêtre sera rejetée. Les réessais réseau légitimes restent tolérés car ils conduisent au même traitement idempotent côté métier, la requête originale étant déjà envoyée en file.

Rotation de secrets sans interruption

La rotation s’appuie sur deux secrets en parallèle: K1 (actif) et K0 (secondaire). L’endpoint vérifie la signature contre l’ensemble {K1, K0} et incrémente des métriques par clé pour vérifier la prise en charge par le fournisseur. Un plan de rotation sans downtime s’exécute en quatre étapes: publier K1 chez le fournisseur comme “nouvelle clé”, activer la double vérification côté serveur, basculer la signature fournisseur sur K1, puis retirer K0 après un délai de sécurité observé dans les métriques (plus aucun événement signé par K0). Si le fournisseur supporte un keyId, insérez-le dans l’en-tête (x-key-id) pour une résolution directe et des métriques plus nettes. À défaut, on tente K1 puis K0 dans cet ordre et on expose des compteurs par clé. Automatisez la rotation avec un job planifié qui charge les secrets versionnés depuis votre gestionnaire (Vault/SSM), effectue un canary en sandbox et commute progressivement en production.

Exemple d’environnement pour les secrets et la rotation:

export WEBHOOK_PROVIDER_ID=acme
export WEBHOOK_ACTIVE_KEY_ID=K1
export WEBHOOK_SECRET_K1='prod-secret-k1'
export WEBHOOK_SECRET_K0='prod-secret-k0'
export WEBHOOK_WINDOW_SECONDS=300
export REDIS_URL='redis://localhost:6379'

Durcissement du point d’entrée

Le durcissement combine plusieurs garde-fous. Côté proxy et applicatif, limitez le débit par IP et par clé pour éviter les rafales, filtrez le User-Agent si votre fournisseur en utilise un prévisible, et refusez toute méthode autre que POST. Fixez une limite stricte de taille de corps (256 KB dans l’exemple), des timeouts courts de lecture et de traitement, et attendez la validation complète de la signature avant de désérialiser. Contrôlez ensuite le schéma JSON (via Ajv/JSON Schema) et rejetez les champs inattendus pour réduire la surface d’attaque. Enfin, découplez le traitement métier en répondant rapidement (202/200) et en poussant le payload validé dans une file, comme un Redis Stream, une queue SQS ou un broker (RabbitMQ/Kafka). Cela isole l’endpoint des lenteurs et pannes du backend métier, et facilite la mise en place d’une idempotence côté consommateur de la file.

Reverse proxy Nginx: garde-fous en amont

Nginx doit encadrer le flux pour éviter que l’application ne lise des payloads énormes ou des méthodes inattendues. La configuration suivante illustre des protections de base: limite de taille, limite de débit, acceptation de POST uniquement, buffering activé et hygiène des en-têtes.

# http {...} (extrait)
limit_req_zone $binary_remote_addr zone=webhook_zone:10m rate=5r/s;

server {
  listen 80;
  server_name example.com;

  # Optionnel: allowlist IPs du fournisseur si publiées (complément à la signature)
  # geo $allowed_ip {
  #   default 0;
  #   203.0.113.0/24 1;
  # }
  # if ($allowed_ip = 0) { return 403; }

  client_max_body_size 256k;

  location = /webhooks/acme {
    limit_except POST { return 405; }

    # Limitation de débit (burst autorise de petites rafales)
    limit_req zone=webhook_zone burst=20 nodelay;

    # Nettoyage / hop-by-hop
    proxy_set_header Connection "";
    proxy_set_header Upgrade "";
    proxy_set_header Proxy "";
    proxy_set_header Keep-Alive "";
    proxy_set_header Transfer-Encoding "";

    proxy_request_buffering on;   # éviter le streaming non contrôlé
    proxy_read_timeout 5s;
    proxy_send_timeout 5s;

    proxy_pass http://127.0.0.1:8080;
  }
}

Cette configuration rejette toute méthode autre que POST, limite la taille, cadre le débit et évite que l’application ne traite des flux non tamponnés. Conservez la liste d’IP du fournisseur si elle existe, mais uniquement en complément de la signature HMAC.

Observabilité, métriques et alerting

Les journaux doivent être structurés afin de permettre des analyses rapides: incluez le providerId, un eventId si disponible (après parsing), un keyId, et un résultat codifié (ok, bad_sig, replay, expired_ts, too_large, etc.). Des compteurs Prometheus par raison d’échec donnent une vision claire du bruit et des attaques, tandis qu’un histogramme de latence isole les régressions. La mesure du skew d’horloge observé aide à diagnostiquer un hôte désynchronisé. Complétez par du traçage applicatif: à la réception, émettez un identifiant de corrélation qui vous suit jusqu’au push en file et au traitement métier. Côté alerting, placez des seuils sur les taux de bad_sig et replay anormalement élevés, ainsi que sur le silence radio (absence d’événements pendant une période attendue), signe potentiel de panne côté fournisseur ou de problème réseau.

Tests d’intégration et cas limites

La validation s’appuie sur des vecteurs canoniques. Préparez un payload connu, un secret et la signature attendue, et vérifiez la cohérence cross-langage. Un test simple en bash génère la signature avec OpenSSL et envoie la requête.

SECRET='dev-secret-k1'
BODY='{"id":"evt_1","type":"ping","data":{"ok":true}}'
TS=$(date +%s)
SIG=$(printf "%s|%s" "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -binary | xxd -p -c 256)
curl -i -X POST http://localhost:8080/webhooks/acme \
  -H "content-type: application/json" \
  -H "x-timestamp: $TS" \
  -H "x-signature: $SIG" \
  --data "$BODY"

Le test de rejouement consiste à renvoyer exactement la même requête et à vérifier le blocage. Pour le skew, rejouez avec un timestamp de ±600 secondes et attendez un rejet au-delà de la fenêtre. La rotation s’exerce en positionnant K0 et K1, en activant l’en-tête x-key-id si disponible et en observant les métriques par clé lors de la bascule. Un fuzzing des en-têtes et du schéma JSON permet de s’assurer que des champs inattendus sont refusés, et que des types incorrects sont rejetés. N’oubliez pas les tests de Content-Type et de taille: envoyez un body >256 KB pour observer le rejet côté proxy.

Exemple de signature en PHP pour comparer les résultats:

<?php
$secret = 'dev-secret-k1';
$ts = time();
$body = '{"id":"evt_1","type":"ping","data":{"ok":true}}';
$canonical = $ts . '|' . $body;
$sig = hash_hmac('sha256', $canonical, $secret); // hex
echo "x-timestamp: $ts\nx-signature: $sig\n";

Checklist de mise en production

Avant d’ouvrir l’endpoint, vérifiez que le corps brut est effectivement capturé et que la comparaison HMAC s’effectue en temps constant, avec une fenêtre temporelle appliquée sur le timestamp signé. Confirmez que l’anti-rejeu via Redis fonctionne bien avec SET NX EX et que le TTL réel correspond à votre politique, en simulant un replay. Assurez-vous que la rotation à deux clés est active, documentée et testée: introduisez K1, observez la prise en charge par métriques, puis révoquez K0 selon le plan; conservez un runbook d’urgence en cas de rollback. Passez en revue les limites côté proxy et application (taille, méthodes, débit, timeouts), et vérifiez que les journaux structurés et les métriques sous-jacentes déclenchent bien des alertes pertinentes. Après chaque déploiement, exécutez un playbook de tests: signature valide, signature invalide, replay, timestamp expiré, et scénario de rotation.

Checklist

Relisez l’implémentation complète avec un œil de menace: quelles informations fuitent dans les réponses, les journaux et les métriques, et sont-elles nécessaires? Testez toutes les commandes et snippets fournis (Node, bash, PHP), y compris les scénarios d’échec et de charge. Publiez progressivement derrière Nginx avec les limites activées, surveillez les métriques de validation et d’erreurs, puis annoncez la disponibilité une fois les alertes stabilisées.

Conclusion

En combinant vérification HMAC, fenêtre temporelle stricte, déduplication Redis et rotation de secrets à deux clés, vous obtenez une terminaison webhook qui résiste aux falsifications et aux relectures sans sacrifier la disponibilité. Le reverse proxy filtre l’orage, l’application reste frugale et idempotente, et l’observabilité vous donne les signaux faibles pour réagir vite. Ce modèle est simple à étendre: branchez une queue managée, enrichissez vos schémas et automatisez la rotation depuis votre gestionnaire de secrets.

Ressources