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ón | Respuesta |
|---|---|
| Firma válida | 202 Accepted |
| Sin header, con secreto configurado | 401 |
| Header mal formado | 401 |
| Timestamp fuera de la ventana | 401 |
| Cuerpo alterado después de firmar | 401 |
| Trigger sin secreto | 202 — 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.