Sécurité des webhooks
La sécurité des webhooks est essentielle pour garantir que les requêtes proviennent bien de moncreneau.
Signature HMAC-SHA256
Chaque webhook est signé avec HMAC-SHA256. Le header X-Moncreneau-Signature contient la signature :
X-Moncreneau-Signature: sha256=5d41402abc4b2a76b9719d911017c592
Vérification de la signature
Étape 1 : Récupérer le secret
Lors de la création du webhook, vous recevez un secret :
whsec_abc123def456ghi789jkl012mno345
⚠️ Conservez ce secret en sécurité ! Ne le commitez jamais dans votre code.
Étape 2 : Calculer la signature
Calculez le HMAC du payload avec votre secret :
Node.js
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
// Extraire la signature (enlever le préfixe "sha256=")
const receivedSignature = signature.replace('sha256=', '');
// Calculer la signature attendue
const hmac = crypto.createHmac('sha256', secret);
hmac.update(payload);
const expectedSignature = hmac.digest('hex');
// Comparaison sécurisée
return crypto.timingSafeEqual(
Buffer.from(receivedSignature, 'hex'),
Buffer.from(expectedSignature, 'hex')
);
}
// Utilisation avec Express
app.post('/webhooks/moncreneau', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-moncreneau-signature'];
const secret = process.env.MONCRENEAU_WEBHOOK_SECRET;
if (!verifyWebhookSignature(req.body, signature, secret)) {
return res.status(401).send('Invalid signature');
}
// Maintenant on peut parser le JSON en toute sécurité
const event = JSON.parse(req.body.toString());
handleEvent(event);
res.status(200).send('OK');
});
Java (Spring Boot)
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
@RestController
@RequestMapping("/webhooks")
public class WebhookController {
@Value("${moncreneau.webhook.secret}")
private String webhookSecret;
@PostMapping("/moncreneau")
public ResponseEntity<String> handleWebhook(
@RequestBody String payload,
@RequestHeader("X-Moncreneau-Signature") String signature) {
if (!verifySignature(payload, signature)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Invalid signature");
}
// Parser et traiter l'événement
ObjectMapper mapper = new ObjectMapper();
WebhookEvent event = mapper.readValue(payload, WebhookEvent.class);
handleEvent(event);
return ResponseEntity.ok("OK");
}
private boolean verifySignature(String payload, String signature) {
try {
// Enlever "sha256="
String receivedSig = signature.replace("sha256=", "");
// Calculer HMAC
Mac hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec key = new SecretKeySpec(
webhookSecret.getBytes("UTF-8"),
"HmacSHA256"
);
hmac.init(key);
byte[] hash = hmac.doFinal(payload.getBytes("UTF-8"));
String expectedSig = bytesToHex(hash);
// Comparaison sécurisée
return MessageDigest.isEqual(
receivedSig.getBytes(),
expectedSig.getBytes()
);
} catch (Exception e) {
return false;
}
}
private String bytesToHex(byte[] bytes) {
StringBuilder result = new StringBuilder();
for (byte b : bytes) {
result.append(String.format("%02x", b));
}
return result.toString();
}
}
PHP (Laravel)
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class WebhookController extends Controller
{
public function handleMoncreneau(Request $request)
{
$signature = $request->header('X-Moncreneau-Signature');
$payload = $request->getContent();
$secret = config('moncreneau.webhook_secret');
if (!$this->verifySignature($payload, $signature, $secret)) {
return response('Invalid signature', 401);
}
$event = json_decode($payload, true);
$this->handleEvent($event);
return response('OK', 200);
}
private function verifySignature($payload, $signature, $secret)
{
// Enlever "sha256="
$receivedSignature = str_replace('sha256=', '', $signature);
// Calculer la signature attendue
$expectedSignature = hash_hmac('sha256', $payload, $secret);
// Comparaison sécurisée
return hash_equals($expectedSignature, $receivedSignature);
}
}
Python (Django/Flask)
import hmac
import hashlib
def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
"""Vérifie la signature HMAC d'un webhook."""
# Enlever "sha256="
received_signature = signature.replace('sha256=', '')
# Calculer la signature attendue
expected_signature = hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
# Comparaison sécurisée
return hmac.compare_digest(expected_signature, received_signature)
# Flask
from flask import Flask, request
@app.route('/webhooks/moncreneau', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Moncreneau-Signature')
payload = request.get_data()
secret = os.getenv('MONCRENEAU_WEBHOOK_SECRET')
if not verify_webhook_signature(payload, signature, secret):
return 'Invalid signature', 401
event = request.get_json()
handle_event(event)
return 'OK', 200
# Django
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
@csrf_exempt
def webhook_view(request):
if request.method == 'POST':
signature = request.META.get('HTTP_X_MONCRENEAU_SIGNATURE')
payload = request.body
secret = settings.MONCRENEAU_WEBHOOK_SECRET
if not verify_webhook_signature(payload, signature, secret):
return HttpResponse('Invalid signature', status=401)
event = json.loads(payload)
handle_event(event)
return HttpResponse('OK', status=200)
Bonnes pratiques
✅ À faire
- Toujours vérifier la signature avant de traiter le payload
- Utiliser une comparaison timing-safe pour éviter les timing attacks
- Stocker le secret dans les variables d'environnement
- Logger les signatures invalides pour détecter les tentatives d'attaque
- Utiliser HTTPS en production (obligatoire)
❌ À éviter
- Ne jamais skipper la vérification de signature
- Ne jamais comparer avec
==(vulnérable aux timing attacks) - Ne jamais logger le secret webhook
- Ne jamais utiliser HTTP en production
Rotation du secret
Si votre secret est compromis :
- Générez un nouveau secret dans Dashboard → Webhooks
- Mettez à jour votre code avec le nouveau secret
- Déployez la nouvelle version
- Désactivez l'ancien secret
IP Whitelisting (optionnel)
Pour plus de sécurité, vous pouvez restreindre les requêtes aux IPs de moncreneau :
# IPs moncreneau (à confirmer avec le support)
41.223.45.0/24
41.223.46.0/24
Configuration Nginx :
location /webhooks/moncreneau {
allow 41.223.45.0/24;
allow 41.223.46.0/24;
deny all;
proxy_pass http://localhost:3000;
}
Tests
Générer une signature de test
const crypto = require('crypto');
const payload = JSON.stringify({
id: "evt_test123",
type: "appointment.created",
data: { ... }
});
const secret = "whsec_test_secret";
const signature = "sha256=" + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
console.log(signature);
// sha256=5d41402abc4b2a76b9719d911017c592
Tester avec curl
curl -X POST http://localhost:3000/webhooks/moncreneau \
-H "Content-Type: application/json" \
-H "X-Moncreneau-Signature: sha256=5d41402abc4b2a76b9719d911017c592" \
-d '{"id":"evt_test","type":"appointment.created","data":{}}'