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 :
- Utilisez les bytes raw du body pour le SHA-256 interne. Re-sérialiser le JSON donne un autre hash.
- Comparez la signature à temps constant. Un
==classique fuit du timing exploitable en bruteforce. - 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
| Erreur | Symptôme | Fix |
|---|---|---|
| Re-sérialiser le body JSON avant le hash | La signature ne match jamais | Utilisez les bytes raw — voir notes par framework |
| Dérive d'horloge serveur | Rejets « Timestamp out of window » | Lancez NTP, vérifiez timedatectl status sur Linux |
Comparer avec == au lieu de temps constant | Vulnérabilité timing-attack subtile | Utilisez crypto.timingSafeEqual / hmac.compare_digest / hash_equals |
| Logguer le secret sur disque | Le secret atterrit dans vos logs | Ne le loggez pas ; utilisez un coffre ; rotation si fuite |
| Répondre 200 immédiatement et traiter plus tard | Events perdus si votre worker crash | Persistez 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.