Aller au contenu
Sylvain Joffraud l4w
Retour

Sécuriser un webhook avec un secret dans l’URL (Fastify)

Un backend Fastify reçoit des positions GPS depuis Traccar via le forwarder JSON intégré. Le backend est exposé en HTTPS public (Coolify/Traefik) — donc l’endpoint /webhook/position aussi. Sans auth, n’importe qui peut POSTer de fausses positions et déclencher des alertes côté métier.

Contrainte : la conf Traccar (forward.header) ne sait poser qu’un header statique — pas de signature HMAC dynamique sur le corps, pas de header calculé à partir du payload. Donc le pattern « X-Signature: sha256=hex(hmac(secret, body)) » n’est pas réalisable côté émetteur.

Pattern retenu : secret partagé dans l’URL

Pas un header, un segment de chemin :

POST /webhook/position/<TOKEN_32_OCTETS_B64URL>

Le token est un secret aléatoire généré une fois (crypto.randomBytes(32).toString('base64url')), connu de Traccar et du backend. Le routeur Fastify le capture comme paramètre de route et le compare en timing-safe au secret côté serveur.

import crypto from 'node:crypto';

function tokenMatches(provided) {
  const expected = process.env.WEBHOOK_TOKEN;
  if (!expected || !provided) return false;
  const a = Buffer.from(provided);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

fastify.post('/webhook/position/:token', async (req, reply) => {
  if (!tokenMatches(req.params.token)) {
    return reply.code(401).send({ error: 'Invalid webhook token' });
  }
  // … traitement
});

Trade-offs honnêtes

Zéro modif côté émetteur : Traccar ne fait que dire forward.url=https://…/webhook/position/<TOKEN>. Le secret voyage chiffré dans TLS, illisible sur le réseau.

timingSafeEqual empêche les attaques par chronométrage : sans ça, comparer deux strings avec === permet à un attaquant de deviner le token caractère par caractère via la durée de réponse.

Le token apparaît dans les logs d’accès si l’URL complète est logguée. Mitigation : règle de masquage dans les logs Fastify et Traefik (/webhook/position/<redacted>).

Pas de protection contre le replay : si un attaquant capture une requête (improbable avec TLS), il peut la rejouer. Tolérable ici parce que les positions GPS sont idempotentes côté logique métier (cooldown anti-flood sur les alertes). Si tu manipules des virements bancaires, ce pattern ne suffit pas.

Lessons learned

Questions fréquentes

Comment authentifier un webhook quand l’émetteur ne sait poser qu’un header statique (pas de signature HMAC) ?
Quand l’émetteur ne peut pas calculer une signature dynamique sur le corps (cas de Traccar avec forward.header), mettez un secret aléatoire dans un segment de l’URL plutôt que dans un header : POST /webhook/position/<TOKEN>. Générez le token une fois avec crypto.randomBytes(32).toString(’base64url’), partagez-le entre émetteur et backend, et capturez-le comme paramètre de route Fastify. Zéro modification côté émetteur, qui se contente de pointer son forward.url vers l’URL contenant le token.
Pourquoi comparer un token de webhook avec === est dangereux, et que faire à la place ?
Comparer deux secrets avec === s’arrête au premier caractère qui diffère, ce qui laisse fuiter de l’information par le temps de réponse : un attaquant peut deviner le token caractère par caractère via le chronométrage. Utilisez plutôt crypto.timingSafeEqual, disponible gratuitement dans la stdlib Node, après avoir vérifié que les deux buffers ont la même longueur. Il n’y a aucune excuse pour faire un === sur un secret.
Un secret placé dans l’URL d’un webhook HTTPS est-il visible en clair sur le réseau ?
Non : le chemin (path) de l’URL est chiffré par TLS au même titre que les headers, contrairement à une idée reçue. Le secret n’est donc pas lisible sur le réseau. Le vrai risque de fuite est ailleurs : le token complet apparaît dans les access logs de Fastify et de Traefik. Posez dès le départ une règle de masquage du type /webhook/position/<redacted> pour éviter qu’il fuite dans le premier dump de logs partagé.
Le pattern secret-dans-l’URL protège-t-il contre le rejeu (replay) d’une requête de webhook ?
Non, ce pattern empêche le spoofing mais pas le replay : si quelqu’un capture une requête valide (improbable sous TLS), il peut la rejouer. C’est tolérable quand les données sont idempotentes côté métier, par exemple des positions GPS avec un cooldown anti-flood sur les alertes. Si le rejeu est dangereux (virements bancaires, actions non idempotentes), ajoutez un nonce ou un timestamp signé côté émetteur — mais alors le simple header statique ne suffit plus.

Partager cet article :

Article précédent
Hash de mot de passe en Node : scrypt sans dépendance native
Article suivant
« GT06 » vs MiCODUS : syntaxe SMS et port Traccar différents