Deine Webhooks sollten der URL, die sie bekommen, nicht trauen

Product

Ein Webhook ist die einzige Funktion, bei der ein Kunde eine Adresse in ein Formular tippt und deine Server sie aufrufen. Jede andere Anfrage deines Backends geht an ein Ziel, das du gewählt hast. Diese geht dorthin, wo der Kunde will, so oft dein Produkt Ereignisse erzeugt, und so lange es den Endpoint gibt.

Diese Woche hat Kookee Webhooks bekommen und darauf aufbauend Integrationen mit Slack, Discord, Telegram, GitHub, Jira, Asana und Freshdesk. Du veröffentlichst einen Beitrag, und deine statische Website wird neu gebaut; ein Besucher hinterlässt Feedback, und es landet im Slack-Kanal deines Teams oder wird zur Jira-Aufgabe.

Die Seite Integrationen mit verbundenen GitHub Issues, Slack und Jira, daneben Telegram, GitHub Actions und Discord, bereit zur Einrichtung

Das Formular mit dem URL-Feld hat einen Nachmittag gedauert. Alles Folgende hat den Rest der Woche gedauert, und einiges davon habe ich erst bei der Durchsicht des Plans bemerkt, bevor es eine Zeile Code gab.

Acht Dinge, die ein Webhook-Sender richtig machen muss

1. Prüf die Adresse beim Senden, nicht beim Speichern

Die naheliegende Prüfung lehnt http://localhost und 10.0.0.5 ab, wenn jemand einen Endpoint speichert. Und sie bringt nichts. Die URL wird einmal geprüft, aber der Name darin wird bei jeder Zustellung neu aufgelöst, und nichts hindert hooks.example.com daran, am Montag auf eine öffentliche Adresse zu zeigen und am Dienstag auf 169.254.169.254, den Metadatendienst der Cloud.

Die eigentliche Prüfung passiert deshalb beim Senden. Kookee löst den Host auf, lehnt die Zustellung ab, wenn eine der zurückgegebenen Adressen privat, Loopback, Link-Local oder reserviert ist (auch IPv4 innerhalb von IPv6), und verbindet sich dann genau mit der geprüften Adresse, damit eine zweite Auflösung sie nicht austauschen kann. Weiterleitungen werden nicht verfolgt: Ein 302 auf eine interne Adresse ist einfach eine Antwort.

Ein Bereich wird leicht vergessen: die öffentlichen Adressen deiner eigenen Server. Hinter einem CDN ist die IP deines Origin-Servers genau das, was ein Angreifer am liebsten erreichen will, und ein Webhook würde sie bereitwillig von innerhalb deiner Firewall aufrufen. Die Adressen von Kookee stehen auf der Sperrliste.

2. Signiere so, wie es alle anderen tun

Ich hätte mir einen eigenen Signatur-Header ausdenken können. Stattdessen signiert Kookee Anfragen nach Standard Webhooks: mit webhook-id, webhook-timestamp und einem HMAC in webhook-signature. Wer den Empfänger schreibt, bekommt eine Bibliothek in seiner Sprache, ein Replay-Fenster von fünf Minuten gratis dazu und ein Format weniger zum Lernen.

Der Signaturschlüssel wird verschlüsselt gespeichert und lässt sich im Menü des Endpoints anzeigen und neu generieren. Ein neuer Schlüssel macht den alten sofort ungültig, und genau das willst du nach einem Leak.

3. Ein langsamer Empfänger sollte nur sich selbst ausbremsen

Jede Zustellung wartet auf den Server von jemand anderem. Wenn der Endpoint eines Kunden neun Sekunden für eine Antwort braucht und deine Worker nur wenige Zustellungen gleichzeitig abarbeiten, wird ein einzelner Kunde unbemerkt zur Warteschlange für alle.

Kookee gibt jeder Anfrage ein festes Zeitlimit von 10 Sekunden, DNS-Auflösung eingeschlossen, und führt pro Prozess 25 Zustellungen gleichzeitig aus. Das allein reicht nicht, weil ein langsamer Endpoint, der den ganzen Tag wiederholt wird, diese Plätze trotzdem belegt. Deshalb bekommt jeder Endpoint zweieinhalb Minuten Wartezeit pro Stunde und jedes Konto siebeneinhalb Minuten über alle seine Endpoints. Eine Zustellung über diesem Budget wird übersprungen, und der Endpoint sagt das in klaren Worten. Ein Überspringen zählt nie als Fehlschlag: Der Empfänger ist langsam, nicht kaputt, und ihn abzuschalten würde das Falsche bestrafen.

Inhaltsereignisse und Besucherereignisse werden getrennt gezählt. Eine Flut von Spam-Feedback darf nie der Grund sein, dass deine Website nach dem Veröffentlichen nicht neu gebaut wurde.

4. Stundenlang wiederholen, dann aufhören und Bescheid geben

Server fallen für Deployments aus und kommen wieder. Eine fehlgeschlagene Zustellung wird zehnmal über etwa achteinhalb Stunden wiederholt, mit Wartezeiten von 1, 2, 4 und bis zu 256 Minuten, jedes Mal mit derselben webhook-id, damit der Empfänger Duplikate verwerfen kann.

Was ich nicht wollte, ist ein Endpoint, der seit März still vor sich hin scheitert. Eine Antwort 410 Gone schaltet einen Endpoint sofort ab. Fünf Ereignisse in Folge, bei denen jede Wiederholung scheitert, schalten ihn ebenfalls ab. In beiden Fällen bekommt der Inhaber des Projekts eine Benachrichtigung und eine E-Mail mit dem Endpoint und dem Grund, und das Wiedereinschalten ist ein Klick.

5. Sende das Ereignis, wenn sich die Seite ändert, nicht die Zeile

„Eintrag aktualisiert“ klingt einfach, bis du zählst, was es alles auslöst. Autosave, ein korrigierter Tippfehler in einem Entwurf, eine verknüpfte Übersetzung: Ein naiver Hook baut eine statische Website an einem Nachmittag vierzigmal neu.

Kookees Inhaltsereignisse folgen dem, was ein Besucher sehen kann. entry.published feuert, wenn ein Eintrag sichtbar wird, auch wenn ein geplanter Beitrag um 9:00 Uhr online geht und niemand an der Tastatur sitzt: Für jedes Datum in der Zukunft wartet ein Job. entry.updated feuert nur für einen Eintrag, der sichtbar war und es noch ist. Ein Entwurf, der zehnmal gespeichert wird, sendet nichts.

6. Jedes Ziel hat seine eigene Art, Leute anzupingen

Integrationen posten Text, den ein Fremder geschrieben hat, in die Tools deines Teams, und jedes Tool hat sein eigenes Markup, um Aufmerksamkeit zu erregen. Ein Feedback-Beitrag mit dem Titel <!channel> urgent würde einen ganzen Slack-Kanal anpingen. In Discord heißt derselbe Trick @everyone, dazu kommt ein maskierter Link, der verbirgt, wohin er führt. In GitHub hinterlässt #123 oder owner/repo#1 im Text eines Issues einen Querverweis im Issue von jemand anderem, und zwar von deinem Konto aus.

Deshalb bekommt jeder Dienst sein eigenes Escaping. Bei Slack werden die Sonderzeichen escapt, und die Link-Vorschau (Unfurling) ist aus. Bei Discord wird jedes Markdown-Zeichen escapt, Erwähnungen sind deaktiviert und Embeds unterdrückt. Text für Telegram wird HTML-escapt. In einem GitHub-Issue steht der Text des Besuchers in einem Codeblock, dessen Fence länger ist als jede Folge von Backticks darin. Freshdesk bekommt HTML-escapten Text, weil der Text eines Tickets dort HTML ist. Und Jira lehnt eine Beschreibung mit einem leeren Absatz ab, also werden Leerzeilen entfernt, bevor sie dort ankommen.

Davon sieht man nichts, solange es funktioniert. Es ist der Unterschied zwischen einem Feedback-Tool und einem Weg, über den jeder deiner ganzen Firma schreiben kann.

7. Zugangsdaten fahren nie in der Queue mit

Eine Slack-Webhook-URL ist ein Passwort: Wer sie hat, kann in deinen Kanal posten. Dasselbe gilt für das Token eines Telegram-Bots, das direkt in der URL der API steht. Das verlockende Design legt die entschlüsselte Konfiguration in den Zustellungsjob, damit der Worker alles hat, was er braucht. Dann liegt jedes Token im Klartext in Redis, in jeder Wiederholung, stundenlang.

Kookees Jobs enthalten eine Integrations-ID, eine Ereignis-ID und das Ereignis. Der Worker lädt die Zeile, entschlüsselt die Konfiguration im letzten Moment und schreibt nie eine Anfrage-URL ins Log. Auch aus dem Audit-Log bleiben Tokens draußen: Es hält fest, wer was geändert hat, aber nicht das Secret, das dabei eingegeben wurde.

8. Lies die Regeln der Gegenseite, bevor du deine eigenen schreibst

Mein erster Plan behandelte 403 Forbidden als „diese Integration wird nie funktionieren“ und schaltete die Integration ab. Der HTTP-Client von Node sendet keinen User-Agent-Header, solange du keinen setzt, und GitHub beantwortet eine Anfrage ohne ihn mit 403. Jede GitHub-Integration hätte sich bei ihrer ersten Zustellung selbst abgeschaltet.

Die Durchsicht des Plans hat das gefunden, und jede Anfrage trägt jetzt content-type und einen User-Agent. Die Lektion reichte über einen Header hinaus: GitHub beantwortet auch Rate Limits mit 403, also wird in diesem Fall wiederholt, und Telegram meldet einen gelöschten Chat als 400 mit einer Nachricht, also wird in diesem Fall abgeschaltet. Jeder Dienst bekommt eine kurze Liste von Ausnahmen von einer einfachen Regel, festgehalten neben dem Code, der an ihn sendet.

Wenn du Webhooks baust

  • Löse den Host beim Senden auf, lehne private und reservierte Adressen ab, verbinde dich mit der geprüften Adresse und folge keinen Weiterleitungen.

  • Setz die öffentlichen Adressen deiner eigenen Server auf die Sperrliste.

  • Signiere nach Standard Webhooks statt mit einem eigenen Format.

  • Gib jeder Zustellung ein festes Zeitlimit und jedem Endpoint und jedem Konto ein Zeitbudget, damit ein langsamer Empfänger nicht alle aufhält.

  • Wiederhole stundenlang mit einer stabilen Nachrichten-ID, dann schalte den Endpoint ab und sag seinem Inhaber Bescheid.

  • Löse Inhaltsereignisse bei dem aus, was Besucher sehen, nicht bei jedem Speichern.

  • Escape Text für das Markup des jeweiligen Ziels, bevor du ihn postest.

  • Halte Zugangsdaten aus der Queue, den Logs und dem Audit-Log heraus.

Webhooks gibt es in jedem Kookee-Plan, Integrationen bei Pro und Ultra. Ereignisse per Webhook an deine Website senden beschreibt die Ereignisse, den Payload und die Prüfung der Signatur.

Weiterlesen