Aller au contenu principal

Vérifier les signatures

Chaque webhook envoyé par DZBuild porte une signature HMAC-SHA256. Votre handler DOIT la vérifier avant de faire confiance au body.

La recette

expected = hex( hmac_sha256( WEBHOOK_SECRET, X-DZ-Timestamp + "." + sha256_hex(raw_body) ) )

if (!constant_time_equal(expected, X-DZ-Signature)) reject 401
if (abs(now - X-DZ-Timestamp) > 300) reject 401 # fenêtre rejeu ±5 min

Trois règles pour rester safe :

  1. Utilisez les bytes raw du body pour le SHA-256 interne. Re-sérialiser le JSON donne un autre hash.
  2. Comparez la signature à temps constant. Un == classique fuit du timing exploitable en bruteforce.
  3. Rejetez les timestamps périmés (>5 min de l'horloge serveur). Lancez NTP.

Code

Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const WEBHOOK_SECRET = process.env.DZBUILD_WEBHOOK_SECRET;

// IMPORTANT : capturez le body raw pour HMAC, séparément du JSON parsé.
app.post('/webhooks/dzbuild',
express.raw({ type: 'application/json' }),
(req, res) => {
const ts = req.get('X-DZ-Timestamp');
const sig = req.get('X-DZ-Signature');
if (!ts || !sig) return res.status(401).end();

if (Math.abs(Math.floor(Date.now()/1000) - Number(ts)) > 300) {
return res.status(401).end();
}
const bodyHash = crypto.createHash('sha256').update(req.body).digest('hex');
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${ts}.${bodyHash}`)
.digest('hex');

if (expected.length !== sig.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return res.status(401).end();
}

const event = JSON.parse(req.body.toString('utf8'));
console.log('verified', event.event, event.data);
res.status(200).end();
});

app.listen(3000);
PHP (raw)
<?php
$secret = getenv('DZBUILD_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_DZ_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_DZ_SIGNATURE'] ?? '';

if ($ts === '' || $sig === '') { http_response_code(401); exit; }
if (abs(time() - (int)$ts) > 300) { http_response_code(401); exit; }

$bodyHash = hash('sha256', $body);
$expected = hash_hmac('sha256', $ts . '.' . $bodyHash, $secret);

if (!hash_equals($expected, strtolower($sig))) {
http_response_code(401);
exit;
}

$event = json_decode($body, true);
http_response_code(200);

En Laravel, utilisez un middleware de route ou un controller qui lit $request->getContent() pour le body raw. Désactivez CSRF sur la route webhook.

Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = os.environ['DZBUILD_WEBHOOK_SECRET'].encode()

@app.post('/webhooks/dzbuild')
def receive():
ts = request.headers.get('X-DZ-Timestamp', '')
sig = request.headers.get('X-DZ-Signature', '')
if not ts or not sig: abort(401)
if abs(int(time.time()) - int(ts)) > 300: abort(401)

body = request.get_data()
body_hash = hashlib.sha256(body).hexdigest()
expected = hmac.new(WEBHOOK_SECRET, f"{ts}.{body_hash}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig.lower()): abort(401)

event = request.get_json()
print('verified', event['event'], event['data'])
return '', 200
Go (net/http)
package main

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"time"
)

var secret = []byte(os.Getenv("DZBUILD_WEBHOOK_SECRET"))

func receive(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil { http.Error(w, "read", 400); return }

ts := r.Header.Get("X-DZ-Timestamp")
sig := r.Header.Get("X-DZ-Signature")
if ts == "" || sig == "" { http.Error(w, "no sig", 401); return }

tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil { http.Error(w, "ts", 401); return }
if abs(time.Now().Unix() - tsInt) > 300 { http.Error(w, "stale", 401); return }

h := sha256.Sum256(body)
bodyHash := hex.EncodeToString(h[:])
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(ts + "." + bodyHash))
expected := hex.EncodeToString(mac.Sum(nil))

if !hmac.Equal([]byte(expected), []byte(sig)) {
http.Error(w, "bad sig", 401); return
}
w.WriteHeader(http.StatusOK)
}

func abs(x int64) int64 { if x < 0 { return -x }; return x }

Erreurs courantes

ErreurSymptômeFix
Re-sérialiser le body JSON avant le hashLa signature ne match jamaisUtilisez les bytes raw — voir notes par framework
Dérive d'horloge serveurRejets « Timestamp out of window »Lancez NTP, vérifiez timedatectl status sur Linux
Comparer avec == au lieu de temps constantVulnérabilité timing-attack subtileUtilisez crypto.timingSafeEqual / hmac.compare_digest / hash_equals
Logguer le secret sur disqueLe secret atterrit dans vos logsNe le loggez pas ; utilisez un coffre ; rotation si fuite
Répondre 200 immédiatement et traiter plus tardEvents perdus si votre worker crashPersistez dans votre queue d'abord puis ack, OU faites le travail synchronement et ackez en dernier

Idempotence côté vous

Le même delivery_id PEUT arriver plusieurs fois si notre couche de livraison croit que la première tentative a échoué (split réseau, votre endpoint lent). Dédupliquez côté réception :

INSERT INTO webhook_log (delivery_id, event, body) VALUES (?, ?, ?);
-- Catch l'erreur UNIQUE sur delivery_id → déjà traité, retournez 200 quand même

Ce pattern garantit que même si nous re-pingons, vous ne faites le travail qu'une fois et ackez vite.