post

Signed Webhooks in SubKit

Wer Webhooks zwischen Contentkit und SubKit über die exakten Rohbytes signiert und gegen Wiederholung absichert, macht aus einem HTTP-Aufruf ein nachvollziehbares Governance-Ereignis.

Signed Webhooks in SubKit

Ein Webhook ist erst einmal nur ein POST. Jemand schickt einen JSON-Body an eine öffentliche URL, und auf der anderen Seite passiert etwas: ein Workflow startet, ein Agent läuft, ein Dokument wird publiziert. Genau diese Offenheit ist das Problem. Eine öffentliche URL nimmt per Definition alles an, was sie erreicht, und ohne weiteren Mechanismus kann der Empfänger nicht unterscheiden, ob ein Ereignis von Contentkit stammt oder von jemandem, der die URL aus einem Logfile gefischt hat.

Meine These für diesen Text ist schmal und unbequem: Ein Webhook wird nicht dadurch zum belastbaren Ereignis, dass er ankommt, sondern dadurch, dass seine Signatur über die exakten empfangenen Bytes geprüft wird, bevor irgendetwas anderes geschieht. Alles, was nach einer ungeprüften Annahme im Audit Trail landet, ist im besten Fall plausibel und im schlechtesten Fall frei erfunden. Wenn SubKit jeden Lauf als geprüfte, protokollierte und wiederholbare Ausführung behandelt, dann muss der Auslöser dieselbe Sorgfalt verdienen wie die Ausführung selbst.

Was Contentkit und SubKit hier verbindet

Contentkit ist eine Publishing-Pipeline mit klaren Zuständen. Markdown ist die Eingabe, eine Revision ist ein unveränderbarer Stand, Preview ist der Vertrauensschritt vor der Veröffentlichung, Release setzt einen aktivierten Zeiger, und Rollback ist die kontrollierte Rückkehr zu einem früheren Stand. Jeder dieser Übergänge ist ein Ereignis, das andere Systeme interessieren kann: Eine neue Revision soll vielleicht eine Linkprüfung anstoßen, ein Release soll einen Cache invalidieren und einen Index neu aufbauen, ein Rollback soll eine Benachrichtigung auslösen.

SubKit ist die Seite, die aus solchen Ereignissen geführte Abläufe macht. Die Plattform führt Workflows als governte Ausführungen aus, jede Ausführung ist rechtegeprüft, aufgezeichnet und wiederholbar. Damit ein Contentkit-Ereignis dort etwas auslösen darf, braucht es einen Eingang, der drei Dinge gleichzeitig leistet: Er ist öffentlich erreichbar, er weist den Absender kryptografisch nach, und er verhindert, dass dasselbe Ereignis zweimal wirkt. In der SubKit-Referenz heißt dieser Eingang signed ingress mit Replay-Dedup, und die Bindung zwischen URL und Workflow ist ein inbound hook.

Die Signatur prüft Bytes, nicht Bedeutung

Der häufigste konzeptionelle Fehler beim Verifizieren ist, die Signatur gegen das geparste Objekt zu prüfen statt gegen die Bytes, die über die Leitung kamen. Eine HMAC-Signatur ist eine Funktion über eine exakte Bytefolge und einen geheimen Schlüssel. Sobald der Body durch einen JSON-Parser läuft und danach wieder serialisiert wird, ändern sich Schlüsselreihenfolge, Whitespace und Zahlenformatierung, und der berechnete Hash passt nicht mehr zum gesendeten. Die SubKit-Referenz formuliert das als harte Regel für jeden Verifier: Er liest den exakten Roh-Body und serialisiert vor der Prüfung niemals neu.

Welches Signaturschema gilt, entscheidet das Feld verifier an der Bindung. Der Standardfall ist standard, also die Spezifikation Standard Webhooks. Sie ist im Stand 2026 die naheliegende Wahl, weil sie genau das Problem adressiert, das man sonst pro Anbieter neu lösen müsste: ein einheitliches Format für Signatur, Zeitstempel und Ereignis-ID. Die Spezifikation transportiert drei Header. webhook-id ist die eindeutige Kennung der Zustellung, webhook-timestamp ist ein Unix-Zeitstempel in Sekunden, und webhook-signature trägt die eigentliche Signatur. Signiert wird nicht der Body allein, sondern die Verkettung aus Kennung, Zeitstempel und Payload, getrennt durch Punkte:

msg_2KWPBgLlAfxdpx2AI54pPJ85f4W.1718193600.{"event":"release.activated","revision":"rev_2f","slug":"signed-webhooks-in-subkit"}

Über diese Zeichenkette läuft HMAC-SHA256. Das gemeinsame Geheimnis ist base64-kodiert und trägt zur Erkennung das Präfix whsec_; die Spezifikation verlangt eine Länge zwischen 24 und 64 Byte. Die fertige Signatur erscheint mit einem Versionspräfix, etwa v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=. Mehrere durch Leerzeichen getrennte Signaturen im selben Header sind zulässig, und das ist kein Detail: So lässt sich ein Secret ohne Ausfallfenster rotieren, weil der Sender für eine Übergangszeit mit altem und neuem Schlüssel gleichzeitig signiert.

Eine Verifikation, die diese Punkte ernst nimmt, sieht in TypeScript ungefähr so aus. Wichtig ist, dass der Roh-Body als Buffer hereinkommt und nicht als bereits geparstes Objekt.

import { createHmac, timingSafeEqual } from "node:crypto";

type VerifyResult =
  | { ok: true; webhookId: string }
  | { ok: false; reason: "missing_headers" | "stale" | "bad_signature" };

const TOLERANCE_SECONDS = 5 * 60;

function verifyStandardWebhook(
  rawBody: Buffer,
  headers: Record<string, string | undefined>,
  secretB64: string, // ohne das "whsec_"-Präfix
): VerifyResult {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  const sigHeader = headers["webhook-signature"];
  if (!id || !ts || !sigHeader) return { ok: false, reason: "missing_headers" };

  // Replay-Schutz auf Zeitebene: zu alte oder zukuenftige Stempel ablehnen.
  const drift = Math.abs(Date.now() / 1000 - Number(ts));
  if (!Number.isFinite(drift) || drift > TOLERANCE_SECONDS) {
    return { ok: false, reason: "stale" };
  }

  // Exakt die empfangenen Bytes signieren, niemals neu serialisieren.
  const signedContent = `${id}.${ts}.${rawBody.toString("utf8")}`;
  const key = Buffer.from(secretB64, "base64");
  const expected = createHmac("sha256", key).update(signedContent).digest();

  // Header kann mehrere "v1,<base64>"-Signaturen enthalten (Secret-Rotation).
  for (const part of sigHeader.split(" ")) {
    const [, b64] = part.split(",");
    if (!b64) continue;
    const candidate = Buffer.from(b64, "base64");
    if (
      candidate.length === expected.length &&
      timingSafeEqual(candidate, expected)
    ) {
      return { ok: true, webhookId: id };
    }
  }
  return { ok: false, reason: "bad_signature" };
}

Drei Stellen sind hier nicht verhandelbar. Der Vergleich läuft über timingSafeEqual, weil ein gewöhnliches === über die Antwortzeit verrät, wie viele führende Bytes stimmen, und damit ein schrittweises Erraten der Signatur erlaubt. Der Zeitstempel wird gegen die aktuelle Zeit geprüft, mit einer Toleranz von hier fünf Minuten, was als Fenster üblich ist und alte Mitschnitte aussortiert. Und die Schleife über die Signaturteile macht die Rotation überhaupt erst nutzbar, statt sie als theoretische Möglichkeit in der Spezifikation liegen zu lassen.

Wer statt standard einen anbieterspezifischen Verifier braucht, arbeitet nach demselben Muster mit anderen Headern. GitHub etwa legt die Signatur in X-Hub-Signature-256 ab, mit dem Präfix sha256= und einem HMAC-Hexdigest über den rohen Body, und auch die GitHub-Dokumentation besteht ausdrücklich auf einem konstantzeitigen Vergleich und der Behandlung der Nutzlast als UTF-8. Das Schema unterscheidet sich im Detail, das Prinzip nicht: geheimer Schlüssel, exakte Bytes, zeitsicherer Vergleich.

Der Fehlerfall, den fast jeder einmal baut

Ich habe diesen Bug in mehreren Projekten gesehen, und er ist deshalb so zäh, weil er sich nicht als Sicherheitsproblem tarnt, sondern als kaputter Endpunkt. In einer typischen Node-Anwendung steht ganz oben eine Body-Parser-Middleware, die jeden eingehenden JSON-Request liest und in req.body als Objekt ablegt. Die rohen Bytes sind danach konsumiert und verloren. Im Webhook-Handler greift man nun auf req.body, serialisiert es mit JSON.stringify zurück, berechnet darüber den HMAC, und der stimmt nie.

// Falsch: signiert eine RE-serialisierte Fassung, nicht das Original.
app.use(express.json());
app.post("/hooks/contentkit", (req, res) => {
  const reSerialized = JSON.stringify(req.body); // Reihenfolge, Whitespace geaendert
  const sig = createHmac("sha256", key).update(reSerialized).digest("base64");
  // sig != gesendete Signatur -> 401, obwohl der Absender echt ist
});

Was danach passiert, ist das eigentlich Gefährliche, denn der Druck wirkt in die falsche Richtung. Der Webhook ist offensichtlich echt, Contentkit hat ihn nachweislich geschickt, die Verifikation schlägt aber fehl. Der schnelle Ausweg unter Termindruck ist, die Prüfung zu lockern oder ganz auszuschalten, weil sie ja „falsch positiv“ sei. Damit ist der Endpunkt wieder offen, und der Audit Trail protokolliert ab jetzt Ereignisse, deren Herkunft niemand mehr garantieren kann.

Die Lösung ist nicht, die Signatur anders zu berechnen, sondern den Roh-Body zu erhalten. Für die Hook-Route registriert man einen Raw-Parser und prüft die Signatur, bevor irgendetwas geparst wird.

// Richtig: Roh-Bytes nur für die Hook-Route, Verifikation zuerst.
app.post(
  "/hooks/contentkit",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const result = verifyStandardWebhook(req.body, req.headers as any, secret);
    if (!result.ok) return res.status(401).json({ code: "bad_signature" });

    const event = JSON.parse(req.body.toString("utf8")); // erst NACH der Prüfung
    // ... ab hier ist der Absender nachgewiesen
  },
);

Die Reihenfolge ist die ganze Pointe. Erst verifizieren, dann parsen. Wer parst, bevor er prüft, hat die Information, die er zum Prüfen braucht, schon weggeworfen.

Eine echte Signatur reicht nicht: Wiederholung

Eine gültige Signatur beweist Herkunft und Unversehrtheit, aber nicht Einmaligkeit. Genau diesen Punkt unterschätzen viele Implementierungen. Ein Angreifer, der einen echten, korrekt signierten Request mitschneidet, kann ihn unverändert erneut senden, und die Signaturprüfung wird ihn anstandslos durchwinken, weil er ja echt ist. Bei einem Release-Ereignis bedeutet das eine doppelte Veröffentlichung, bei einem Rollback eine doppelte Rücknahme, bei einem Zahlungsereignis Schlimmeres.

Der Zeitstempel deckelt das Risiko, beseitigt es aber nicht. Innerhalb des Toleranzfensters von fünf Minuten ist eine Wiederholung weiterhin möglich, und außerdem dürfen Sender legitim wiederholen: Wenn der Empfänger nicht rechtzeitig mit 200 antwortet, schickt eine korrekte Webhook-Quelle dasselbe Ereignis erneut. Beide Fälle, der bösartige Replay und das gutartige Retry, sehen auf der Leitung identisch aus. Der Empfänger muss sie über die Ereignisidentität auseinanderhalten, nicht über die Absicht.

SubKit löst das mit einer Dedup-Tabelle. Hinter dem Eingang steht webhook_replay_seen mit einem eindeutigen Index über (tenant_id, webhook_id, standard_webhook_id). Dabei ist standard_webhook_id die anbietereigene Ereignis-ID aus dem normalisierten Event, also bei Standard Webhooks der Wert aus webhook-id. Die Verarbeitung versucht ein INSERT dieser Kennung. Gelingt es, ist das Ereignis neu und wird ausgeführt. Verletzt es den Unique-Index, war das Ereignis schon da, und der Lauf wird übersprungen, während der Eingang trotzdem mit 200 antwortet, damit der Sender aufhört zu wiederholen.

-- Idempotenz-Tor: nur das erste Auftreten einer webhook-id loest aus.
INSERT INTO webhook_replay_seen (tenant_id, webhook_id, standard_webhook_id)
VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, webhook_id, standard_webhook_id) DO NOTHING
RETURNING standard_webhook_id;

Liefert das RETURNING eine Zeile, war das Ereignis neu und der Workflow darf starten. Bleibt das Ergebnis leer, hat ein früherer Request denselben Schlüssel bereits belegt, und der Handler quittiert ohne erneute Ausführung. Die Eindeutigkeit liegt in der Datenbank, nicht in der Anwendungslogik, und genau deshalb hält sie auch unter parallelen Zustellungen, bei denen zwei Kopien desselben Events fast gleichzeitig eintreffen.

Vom geprüften Ereignis zum geführten Lauf

Die Bindung zwischen Eingang und Ausführung sitzt im Workflow selbst. Eine Workflow-Definition trägt einen optionalen trigger-Block, der eine über das Feld kind unterschiedene Variante wählt. Lässt man ihn weg, gilt { kind: "request" }, der Workflow wird also direkt über einen Aufruf gestartet. Für den Webhook-Pfad ist die Variante webhook relevant, daneben gibt es schedule für zeitgesteuerte Läufe. Diese drei Auslöser, request, webhook und schedule, sind die einzigen Wege, auf denen eine Ausführung entsteht, und das ist eine bewusste Verengung: Es gibt keinen vierten, undokumentierten Weg, auf dem etwas an der Governance vorbei laufen könnte.

Der gesamte Ablauf, von Contentkit bis zur protokollierten Ausführung, lässt sich als ein Pfad mit zwei Toren lesen.

flowchart TD
  CK[Contentkit: release.activated] -->|POST + 3 Header| Ingress[SubKit signed ingress]
  Ingress --> Raw[Roh-Body lesen, nicht parsen]
  Raw --> Sig{Signatur über Rohbytes gültig?}
  Sig -->|nein| Reject[401 bad_signature]
  Sig -->|ja| Time{Zeitstempel in Toleranz?}
  Time -->|nein| Stale[401 stale]
  Time -->|ja| Dedup{webhook-id schon gesehen?}
  Dedup -->|ja| Ack[200, kein erneuter Lauf]
  Dedup -->|nein| Bind[inbound hook -> Workflow, kind: webhook]
  Bind --> Exec[Governte Ausführung: rechtegeprüft]
  Exec --> Audit[Aufgezeichnet und wiederholbar]

An den beiden Rauten entscheidet sich, ob aus einem Request ein Ereignis wird. Die erste prüft Echtheit über die Bytes, die zweite prüft Einmaligkeit über die Kennung. Erst danach greift die Bindung, und ab da übernimmt die normale SubKit-Maschinerie: Der Lauf wird gegen die Rechte des Mandanten geprüft, mit der freigegebenen Workflow-Version ausgeführt und so aufgezeichnet, dass er später wiederholbar bleibt.

Beobachten lässt sich das Ergebnis über die Lese-Werkzeuge, die SubKit als read-only-Tools in der Kategorie tenant_read bereitstellt. list_inbound_hooks zeigt die für den Mandanten konfigurierten Bindungen, get_inbound_hook die einzelne Bindung im Detail. Bezeichnend ist, dass diese Tools nur lesen. Eine Bindung anzulegen oder ein Secret zu setzen ist kein beiläufiger Tool-Aufruf, sondern eine konfigurierende Handlung mit eigener Berechtigung, und diese Trennung zwischen Beobachten und Verändern ist dieselbe Linie, die auch zwischen einem Webhook und seiner Verifikation verläuft.

Warum die Grenze genau hier liegt

Man könnte einwenden, das alles sei viel Aufwand für einen POST. Der Aufwand erklärt sich aus dem, was am Ende der Kette steht. Wenn ein Webhook nur einen Eintrag in einer Liste erzeugt, ist eine schwache Prüfung verkraftbar. Wenn ein Webhook eine governte Ausführung startet, die Inhalte veröffentlicht, Caches invalidiert oder einen Agenten mit Tool-Zugriff laufen lässt, dann ist der Webhook die Vertrauensgrenze des gesamten Systems. Eine Ausführung kann noch so sauber rechtegeprüft, versioniert und auditiert sein; wenn ihr Auslöser nicht nachgewiesen ist, beschreibt der Audit Trail eine Ausführung, deren Anlass niemand garantieren kann.

Deshalb gehören Signaturprüfung über die Rohbytes, Zeitstempeltoleranz und Replay-Dedup zusammen und lassen sich nicht einzeln weglassen. Die Signatur beantwortet, ob das Ereignis echt ist. Der Zeitstempel beantwortet, ob es aktuell ist. Die Dedup-Kennung beantwortet, ob es neu ist. Erst wenn alle drei Antworten ja lauten, ist aus einem anonymen HTTP-Aufruf ein Ereignis geworden, auf das ein nachvollziehbarer Workflow reagieren darf. Genau an dieser Stelle wird die Verbindung zwischen Contentkit und SubKit prüfbar, und nicht erst im Audit-Log danach.

Wer das selbst baut, sollte an einem konkreten Fehlerpfad anfangen, nicht am glücklichen Fall. Schick denselben signierten Request zweimal und sieh nach, ob der zweite wirklich nur quittiert und nicht ausführt. Verändere ein einziges Byte im Body und prüfe, dass die Verifikation kippt. Schick einen zehn Minuten alten Mitschnitt und beobachte die Ablehnung wegen stale. Wenn diese drei Versuche das tun, was sie sollen, trägt die Grenze. Wenn einer durchrutscht, ist es kein kleiner Bug, sondern ein offenes Tor.

Weiterführende Quellen

Kommentare

Kommentar schreiben