Orquesto

Seguridad y operación

Firmar webhooks entrantes

Cómo exigir que cada llamada a un trigger venga firmada, y cómo firmarla del otro lado.

Un trigger de tipo webhook expone una URL pública:

POST https://tu-api/hooks/{tenantId}/{triggerId}

Por defecto, cualquiera que conozca esa URL puede disparar el flujo. El ULID del trigger no es un secreto: es una marca de tiempo ordenable, y las URLs se filtran — a logs de proxy, al historial del navegador, a un cURL pegado en un ticket de soporte.

Activar la firma cierra eso.

Activarla#

Desde el trigger, generá un secreto de firma. Orquesto lo crea y te lo muestra una sola vez:

POST /v1/workflows/{workflowId}/triggers/{triggerId}/signing
{
  "triggerId": "01M0…",
  "secret": "R3D7RA8jKunA6p5VOKUt_IEzxIJV57sB3SAojB1ady8",
  "header": "X-Orquesto-Signature",
  "rotated": false
}

Guardalo donde lo vaya a usar el sistema emisor. No hay forma de volver a leerlo: si se pierde, se rota.

El formato#

X-Orquesto-Signature: t=<unix>,v1=<hex_hmac_sha256>

El MAC es HMAC-SHA256, con el secreto como llave, sobre esta cadena:

<timestamp>.<cuerpo crudo>

El timestamp va dentro de lo firmado, no al lado. Si estuviera solo al lado, alguien podría capturar una llamada vieja, ponerle la hora de ahora y reusarla.

El cuerpo se firma tal cual se envía, byte por byte. Si lo reformateás —reordenar claves, cambiar espacios— el MAC deja de coincidir.

Firmar del lado del emisor#

import hmac, hashlib, time, requests

SECRET = "R3D7RA8jKunA6p5VOKUt_IEzxIJV57sB3SAojB1ady8"
URL = "https://tu-api/hooks/TENANT/TRIGGER"

body = '{"order_id":"A-1"}'          # exactamente lo que se manda
ts = str(int(time.time()))
mac = hmac.new(SECRET.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest()

requests.post(
    URL,
    data=body,                        # data, no json= : no reserializar
    headers={
        "Content-Type": "application/json",
        "X-Orquesto-Signature": f"t={ts},v1={mac}",
    },
)
import { createHmac } from "node:crypto";

const body = JSON.stringify({ order_id: "A-1" });
const ts = Math.floor(Date.now() / 1000).toString();
const mac = createHmac("sha256", SECRET).update(`${ts}.${body}`).digest("hex");

await fetch(URL, {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "x-orquesto-signature": `t=${ts},v1=${mac}`,
  },
  body, // la misma cadena que se firmó
});

Ventana de tiempo#

Una firma vale cinco minutos, hacia adelante y hacia atrás. Fuera de eso se rechaza aunque el MAC sea correcto.

Si te da 401 con firma correcta, lo primero a revisar es el reloj del emisor.

Rotar#

Volver a llamar al mismo endpoint genera un secreto nuevo y el anterior deja de servir de inmediato. No hay período de solapamiento.

Es deliberado: un emisor de webhooks es un sistema configurado, no una flota. Aceptar los dos secretos dejaría funcionando en silencio justo al que quisiste revocar. Actualizá el emisor y rotá en la misma ventana de mantenimiento.

Apagarla#

DELETE /v1/workflows/{workflowId}/triggers/{triggerId}/signing

El trigger vuelve a aceptar llamadas sin firmar. Queda registrado en la auditoría con la misma prominencia que activarla — un control que desaparece en silencio es como se pierde durante meses.

Qué devuelve cada caso#

SituaciónRespuesta
Firma válida202 Accepted
Sin header, con secreto configurado401
Header mal formado401
Timestamp fuera de la ventana401
Cuerpo alterado después de firmar401
Trigger sin secreto202 — acepta sin firma

El cuerpo del 401 nunca dice cuál de los casos fue. Distinguirlos le confirmaría a un atacante qué parte tiene bien.