Aller au contenu principal

Vérification des Webhooks

Guide détaillé pour implémenter la vérification HMAC-SHA256 des webhooks MONCRENEAU.

Pourquoi Vérifier les Signatures ?

Sans vérification, n'importe qui peut envoyer de fausses requêtes à votre endpoint webhook:

# ❌ Attaque possible sans vérification
curl -X POST https://votre-app.com/webhooks/moncreneau \
-d '{"id":"evt_fake","type":"appointment.created","data":{"object":{"id":999}}}'

La signature HMAC garantit que la requête provient bien de MONCRENEAU.

Comment Ça Marche ?

1. MONCRENEAU Génère la Signature

signature = HMAC-SHA256(secret_webhook, payload_json)

2. Vous Vérifiez la Signature

signature_calculée = HMAC-SHA256(votre_secret, payload_reçu)
if (signature_calculée === signature_header) ✅
else ❌

Implémentation Pas à Pas

Node.js / Express

Étape 1: Configuration

// .env
WEBHOOK_SECRET=whsec_abc123xyz789...

// app.js
const express = require('express');
const crypto = require('crypto');

const app = express();

// ⚠️ IMPORTANT: Parser en raw pour webhooks
app.use('/webhooks', express.raw({ type: 'application/json' }));
app.use(express.json()); // Pour les autres routes

Étape 2: Fonction de Vérification

function verifyWebhookSignature(payload, signature, secret) {
// Vérifier que la signature existe
if (!signature) {
console.error('Missing signature header');
return false;
}

// Extraire le hash (format: "sha256=...")
const parts = signature.split('=');
if (parts[0] !== 'sha256') {
console.error('Invalid signature format');
return false;
}
const receivedHash = parts[1];

// Calculer la signature attendue
const expectedHash = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');

// Comparaison timing-safe
try {
return crypto.timingSafeEqual(
Buffer.from(receivedHash, 'hex'),
Buffer.from(expectedHash, 'hex')
);
} catch (error) {
console.error('Signature comparison failed:', error);
return false;
}
}

Étape 3: Route Webhook

app.post('/webhooks/moncreneau', (req, res) => {
const signature = req.headers['x-moncreneau-signature'];
const payload = req.body; // Buffer grâce à express.raw()
const secret = process.env.WEBHOOK_SECRET;

// Vérifier la signature
if (!verifyWebhookSignature(payload, signature, secret)) {
console.error('Invalid signature');
return res.status(401).send('Invalid signature');
}

// Signature valide ✅
const event = JSON.parse(payload.toString());

console.log('✓ Valid webhook received:', event.type);

// Répondre immédiatement
res.status(200).send('OK');

// Traiter en asynchrone
processWebhookAsync(event);
});

Java / Spring Boot

import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;

@RestController
@RequestMapping("/webhooks")
public class WebhookController {

@Value("${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(401).body("Invalid signature");
}

// Parse et traiter l'événement
ObjectMapper mapper = new ObjectMapper();
WebhookEvent event = mapper.readValue(payload, WebhookEvent.class);

processWebhookAsync(event);

return ResponseEntity.ok("OK");
}

private boolean verifySignature(String payload, String signature) {
if (signature == null || !signature.startsWith("sha256=")) {
return false;
}

String receivedHash = signature.substring(7);

try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(
webhookSecret.getBytes("UTF-8"),
"HmacSHA256"
);
mac.init(secretKey);

byte[] expectedBytes = mac.doFinal(payload.getBytes("UTF-8"));
String expectedHash = bytesToHex(expectedBytes);

// Timing-safe comparison
return MessageDigest.isEqual(
expectedHash.getBytes(),
receivedHash.getBytes()
);
} catch (Exception e) {
logger.error("Signature verification failed", 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 handleMoncreneauWebhook(Request $request)
{
$signature = $request->header('X-Moncreneau-Signature');
$payload = $request->getContent();
$secret = config('services.moncreneau.webhook_secret');

if (!$this->verifySignature($payload, $signature, $secret)) {
return response('Invalid signature', 401);
}

$event = json_decode($payload, true);

// Traiter en asynchrone (queue)
dispatch(new ProcessWebhook($event));

return response('OK', 200);
}

private function verifySignature($payload, $signature, $secret)
{
if (!$signature || !str_starts_with($signature, 'sha256=')) {
return false;
}

$receivedHash = substr($signature, 7);
$expectedHash = hash_hmac('sha256', $payload, $secret);

// Timing-safe comparison
return hash_equals($expectedHash, $receivedHash);
}
}

Python / Django

import hmac
import hashlib
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.conf import settings

@csrf_exempt
def webhook_handler(request):
if request.method != 'POST':
return JsonResponse({'error': 'Method not allowed'}, status=405)

signature = request.headers.get('X-Moncreneau-Signature')
payload = request.body
secret = settings.WEBHOOK_SECRET.encode('utf-8')

if not verify_signature(payload, signature, secret):
return JsonResponse({'error': 'Invalid signature'}, status=401)

import json
event = json.loads(payload)

# Traiter en asynchrone (Celery)
from .tasks import process_webhook
process_webhook.delay(event)

return JsonResponse({'status': 'ok'})

def verify_signature(payload, signature, secret):
if not signature or not signature.startswith('sha256='):
return False

received_hash = signature[7:]

expected_hash = hmac.new(
secret,
payload,
hashlib.sha256
).hexdigest()

# Timing-safe comparison
return hmac.compare_digest(expected_hash, received_hash)

Débogage

Vérifier le Payload Reçu

app.post('/webhooks/moncreneau', (req, res) => {
const payload = req.body;

console.log('Payload type:', typeof payload);
console.log('Payload content:', payload);

if (Buffer.isBuffer(payload)) {
console.log('✓ Payload is Buffer (correct)');
} else if (typeof payload === 'string') {
console.log('✓ Payload is string (correct)');
} else {
console.error('✗ Payload is object (WRONG - use express.raw)');
}

// ...
});

Vérifier le Secret

console.log('Secret length:', process.env.WEBHOOK_SECRET?.length);
console.log('Secret starts with:', process.env.WEBHOOK_SECRET?.substring(0, 10));

if (!process.env.WEBHOOK_SECRET) {
console.error('❌ WEBHOOK_SECRET not set!');
}

Comparer les Signatures

function debugSignature(payload, signature, secret) {
console.log('=== SIGNATURE DEBUG ===');

const receivedHash = signature.split('=')[1];
console.log('Received hash:', receivedHash);

const expectedHash = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
console.log('Expected hash:', expectedHash);

console.log('Match:', receivedHash === expectedHash ? '✓' : '✗');

if (receivedHash !== expectedHash) {
console.log('First 10 chars received:', receivedHash.substring(0, 10));
console.log('First 10 chars expected:', expectedHash.substring(0, 10));
}
}

Tester avec curl

# Générer une signature de test
SECRET="whsec_your_secret"
PAYLOAD='{"id":"evt_test","type":"appointment.created"}'

SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/.* //')

echo "Signature: sha256=$SIGNATURE"

# Envoyer la requête
curl -X POST http://localhost:3000/webhooks/moncreneau \
-H "Content-Type: application/json" \
-H "X-Moncreneau-Signature: sha256=$SIGNATURE" \
-d "$PAYLOAD"

Erreurs Courantes

❌ Payload Parsé en JSON Avant Vérification

// MAUVAIS
app.use(express.json()); // Parse TOUT en JSON
app.post('/webhooks', (req, res) => {
const payload = req.body; // Déjà un objet !
// Impossible de calculer la signature sur un objet
});

// BON
app.use('/webhooks', express.raw({ type: 'application/json' }));
app.post('/webhooks', (req, res) => {
const payload = req.body; // Buffer
const signature = calculateSignature(payload); // ✓
});

❌ Secret Incorrect

// Secret hardcodé (MAUVAIS)
const secret = "my-secret";

// Secret depuis env (BON)
const secret = process.env.WEBHOOK_SECRET;

❌ Comparaison Non Timing-Safe

// VULNÉRABLE aux timing attacks
if (receivedHash === expectedHash) { }

// SÉCURISÉ
if (crypto.timingSafeEqual(
Buffer.from(receivedHash, 'hex'),
Buffer.from(expectedHash, 'hex')
)) { }

❌ Charset Encoding

// Peut causer des problèmes
.update(payload, 'utf-8')

// Plus fiable
.update(payload) // Laisse Node gérer l'encoding

Tests

Test Unitaire de la Vérification

const crypto = require('crypto');
const { verifyWebhookSignature } = require('../lib/webhooks');

describe('verifyWebhookSignature', () => {
const secret = 'test_secret';
const payload = '{"id":"evt_123","type":"appointment.created"}';

it('should verify valid signature', () => {
const signature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');

expect(verifyWebhookSignature(payload, signature, secret)).toBe(true);
});

it('should reject invalid signature', () => {
expect(verifyWebhookSignature(payload, 'sha256=invalid', secret)).toBe(false);
});

it('should reject missing signature', () => {
expect(verifyWebhookSignature(payload, null, secret)).toBe(false);
});

it('should reject wrong format', () => {
expect(verifyWebhookSignature(payload, 'md5=abc123', secret)).toBe(false);
});
});

Test d'Intégration avec ngrok

# 1. Démarrer le serveur local
npm start

# 2. Exposer avec ngrok
ngrok http 3000

# 3. Configurer l'URL webhook dans MONCRENEAU
# https://abc123.ngrok.io/webhooks/moncreneau

# 4. Créer un rendez-vous de test
# Les webhooks arrivent dans votre terminal !

Checklist

  • Signature HMAC-SHA256 vérifiée
  • Payload en Buffer/String (pas parsé en JSON)
  • Secret depuis variable d'environnement
  • Comparaison timing-safe
  • Logs de debug en développement
  • Tests unitaires de la vérification
  • Tests d'intégration avec ngrok
  • Gestion des erreurs (401 si signature invalide)

Ressources