Blog ·
Eine Webhook-URL, die das Geheimnis ist
Ein eingehender Webhook kann sein eigener Schlüssel sein, wenn die URL dieses Gewicht trägt. Der Preis: 160 Bit, eine Signatur über die Uhr, ein Abbruch.
Von Provibr
Jemand möchte, dass sein Monitoring-Werkzeug einen unserer Automatisierungsabläufe startet. Das Werkzeug kann JSON an eine URL senden, und das ist alles: keine Signatur, kein eigener Header, kein Clientzertifikat. Es ist ein Gerät im Rack, eingerichtet im Jahr 2019, und es wird nichts Neues mehr lernen.
Man kann diesen Kunden ablehnen oder akzeptieren, dass die URL bei manchen Quellen selbst der Schlüssel ist. Wir haben akzeptiert und danach die gesamte Runde damit verbracht sicherzustellen, dass die URL dieses Gewicht auch wirklich trägt, statt nur lang zu sein.
Zweiunddreißig Zeichen, und niemals abgeleitet
Der Slug in /api/hooks/flows/<slug> besteht aus zweiunddreißig Zeichen eines Alphabets mit zweiunddreißig Symbolen: 160 Bit rohe Entropie. Er wird nicht aus dem Konto, dem Ablauf oder dem Namen des Endpunkts abgeleitet, denn ein Slug, der sich aus bereits veröffentlichten Angaben berechnen lässt, lässt sich von jedem berechnen, der die Statusseite liest.
Das Alphabet ist Crockford-Base32: die Ziffern und die Kleinbuchstaben ohne i, l, o und u. Dieselbe Wahl wie bei unseren Aktivierungscodes für Agents, aus demselben Grund. Diese Zeichenfolge wird in das Verwaltungspanel eines anderen abgetippt, und keine Schriftart macht l und 1 unterscheidbar, wenn jemand müde ist.
Das Geheimnis wird verschlüsselt, nicht gehasht
Endpunkte können zusätzlich ein gemeinsames Geheimnis tragen, als HMAC-Signatur oder als Bearer-Token. Beide werden verschlüsselt abgelegt, und das ist eine Erklärung wert, denn der Instinkt aus zwei Jahrzehnten Passwortratgebern ist hier genau falsch.
Ein Passwort lässt sich hashen, weil nur zu beantworten ist, ob es dieselbe Zeichenfolge ist. Ein HMAC-Geheimnis nicht: Prüfen bedeutet, die Signatur selbst zu berechnen, und dafür wird der Klartext gebraucht. Es geht also unter AES-256-GCM hinein, mit einem Schlüssel aus dem Plattformgeheimnis und mit authentifizierten Zusatzdaten, die den zugehörigen Endpunkt benennen, sodass ein Chiffrat aus einer Zeile nicht als Geheimnis einer anderen Zeile geprüft werden kann. Es ist dieselbe Mechanik, die Zahlungsschlüssel und Skriptgeheimnisse bereits schützt; ein zweiter Mechanismus bedeutete eine zweite Rotationsgeschichte und eine zweite Stelle, an der man sich irren kann.
Wechselt diese Schlüsselquelle jemals, öffnet sich das Chiffrat nicht, und die Anfrage wird abgelehnt statt angenommen; der Bildschirm sagt dann, dass das Geheimnis neu erzeugt werden muss. Bei einem unlesbaren Geheimnis offen zu scheitern ist die Art von Zeile, die man einmal in Eile schreibt und drei Jahre später wiederfindet.
Der Kunde sieht das Geheimnis einmal, beim Anlegen. Nicht weil wir es nicht erneut zeigen könnten, sondern weil „steht jetzt an zwei Stellen“ und „sichtbar für jeden mit Automatisierungsrechten“ verschiedene Risiken sind und nur das erste unvermeidlich ist.
Die Uhr gehört in das signierte Material
Die Signatur ist sha256=<hex> in einem Header, über den Zeitstempel, einen Punkt und den rohen Rumpf. Der Zeitstempel reist in einem eigenen Header und muss auf beiden Seiten innerhalb von fünf Minuten unserer Uhr liegen.
Den Zeitstempel in das signierte Material zu legen statt daneben ist der gesamte Schutz gegen Wiedereinspielung. Signiert man nur den Rumpf, kann jeder, der eine gültige Anfrage mitgeschnitten hat, sie mit einem frischen Zeitstempel-Header für immer wiederholen, und das Fenster wird zur Verzierung. Beide Seiten des Fensters zählen ebenso: Eine Prüfung, die nur „zu alt“ ablehnt, akzeptiert eine Signatur mit der Jahreszahl 2099.
Der Vergleich läuft in konstanter Zeit, denn ein gewöhnlicher Gleichheitstest auf Hextext bricht beim ersten abweichenden Zeichen ab, und dieses Zeitverhalten genügt, um eine Signatur Byte für Byte zu erraten. Und der Zeitstempel wird erst beurteilt, nachdem die Signatur stimmt, niemals davor. Umgekehrt wäre „abgelaufen“ ein kostenloses Orakel: Jemand ganz ohne gültiges Geheimnis erführe, dass seine Uhr das Problem war und nicht seine Signatur. Der Unterschied zwischen falscher und abgelaufener Signatur besteht sehr wohl, aber er besteht in dem Beleg, den der Eigentümer des Endpunkts liest, nicht in der Antwort an den Aufrufer.
Ein Rumpf, der beim Eintreffen abgeschnitten wird
Die Rumpfgrenze liegt standardmäßig bei 64 KiB, und wichtig ist nicht die Zahl, sondern die Stelle, an der sie durchgesetzt wird.
Die Anfrage erst in eine Zeichenfolge zu lesen und dann ihre Länge zu prüfen ist eine Kontrolle nach dem Schaden: Ein Rumpf von 500 MB kostet uns 500 MB vor der Ablehnung, und damit ist die Grenze eine Einladung. Der Rumpf wird deshalb in Stücken aus dem Strom gelesen, die laufende Summe wird bei jedem Stück verglichen, und der Leser wird abgebrochen, sobald sie darüber liegt. Festgehalten werden die Bytes, die wir tatsächlich gelesen haben, nicht die vom Aufrufer behauptete Content-Length, denn ein angreifender Aufrufer ist genau derjenige, dessen Header wir nicht glauben sollten.
Die Durchsatzbremse steht nach dem Rumpf und nicht davor, was dem üblichen Rat widerspricht und so sein muss: Die Signatur wird über den Rumpf berechnet, also kann eine Bremse, die früher antwortet, nicht wissen, ob diese Anfrage berechtigt war. Sie zählt je Endpunkt und nicht je Quelladresse. Eine echte Webhook-Quelle hat eine Adresse; wer angreift, hat tausend, also schützt eine Bremse auf die Adresse genau die falsche Seite.
Neun Ablehnungen, zwölf Ausgänge, und nirgends ein Rumpf
Die Reihenfolge der Ablehnungen ist der Entwurf. Zuerst die Form des Slugs, bevor die Datenbank berührt wird, denn where slug = <ein Megabyte Text> ist Arbeit, zu der ein Unbekannter uns nicht bringen können sollte. Danach Existenz, Widerruf, Deaktivierung, die Adressliste, die Rumpfgrenze, die Durchsatzbremse, die Signatur und zuletzt, ob das JSON überhaupt ein Objekt ist.
Jede Ablehnung ab dem dritten Schritt schreibt einen Beleg. Diese Tabelle ist die Antwort auf „mein Webhook tut nichts“, einen Satz, der zwei ganz verschiedene Probleme verbirgt: Es kommt nichts an, oder es kommt etwas an und wir weisen es ab. Ohne Zeile sind die beiden nicht zu unterscheiden, und der Kunde verbringt seinen Nachmittag mit dem falschen.
Der Beleg trägt den Ausgang als eigenes Wort neben dem HTTP-Status, denn Statuscodes sind dafür eine schlechte Sprache: Eine einzige 403 deckt sowohl „Ihre Adresse steht nicht auf der Liste“ als auch „dieser Endpunkt ist ausgeschaltet“ ab, und beides schickt einen Menschen auf zwei verschiedene Bildschirme. Zwölf Ausgänge, nur anfügbar, mit der Quelladresse und den gelesenen Bytes.
Der Beleg trägt niemals den Rumpf: Das ist die Nutzlast eines anderen, darin kann alles stehen, und eine Bequemlichkeit bei der Fehlersuche wiegt eine Tabelle nicht auf, die still fremde Daten ansammelt. Belege bleiben neunzig Tage stehen, bewusst kürzer als die Ausführungen, zu denen sie gehören, denn eine Quelladresse ist ein personenbezogenes Datum eines Dritten, der nirgends zugestimmt hat.
Eine Ablehnung schreibt nichts: ein unbekannter Slug. Es gibt kein Konto, an das die Zeile gehängt werden könnte, und eine Tabelle, die wächst, sobald ein Fremder rät, ist eine kostenlose Art, unsere Datenbank zu füllen.
Warum 410 und 404 nicht dieselbe Antwort sind
Einen Endpunkt zu widerrufen löscht ihn nicht. Die Zeile bleibt, der Slug bleibt für immer belegt, und eine Anfrage erhält 410 statt 404. Der Name wird wieder verwendbar, denn zwei lebende Endpunkte auseinanderzuhalten war der einzige Zweck dieser Eindeutigkeit, aber die URL kommt nie zurück.
Die verlockende Alternative ist, auf beides mit 404 zu antworten, damit niemand erfährt, welche Slugs einmal existierten. Einen Slug zu raten sind 160 Bit Arbeit, wer also einen besitzt, hat ihn bekommen, aus einer Logzeile, einer alten Integration oder einer Bildschirmaufnahme, und weiß längst, dass er existierte. Der Unterschied sagt ihm nichts.
Der ehrlichen Hälfte der Welt sagt er einiges. Eine Quelle, die nach einer Rotation weiter an die alte URL sendet, erkennt an der 410, dass sie eine neue braucht; an einer 404 schließt sie, dass jemand den Pfad falsch geschrieben hat, und dann sucht ein Mensch einen Fehler im Pfad statt in der Rotation.
Der erste Auslöser ohne Server
Jeder frühere Auslöser lief gegen eine bestehende Maschine: bereitgestellt, neu installiert, gelöscht. Ein Webhook-Lauf hat keine Maschine, keinen Tarif, keine Adresse, keine Integration, keinen Agent und niemanden, der angemeldet ist.
Das klingt klein und war die schärfste Kante der Runde. Der Variablenkatalog vermerkt bei jeder Gruppe, für welche Auslöser sie verfügbar ist, und die Konstante mit der Bedeutung „immer“ war wörtlich die Liste aller Auslöser. Ein fünfter Wert hätte einem Webhook-Ablauf also jede Server-, Tarif-, Adress-, Netzwerk- und Agentvariable in der Auswahl angeboten; der Prüfer hätte sie genehmigt, und die erste echte Anfrage wäre beim Rendern gescheitert, in einem Lauf, dem niemand zusieht.
Es gibt jetzt also eine eigene Liste, die eine andere Frage beantwortet: Läuft dieser Auslöser gegen einen Server. Ein sechster Auslöser muss diese Frage zuerst beantworten. Was einem Webhook-Lauf fehlt, wird aus dem Kontext entfernt statt vorhanden und leer gelassen, und ein Aktionsknoten in einem solchen Graphen wird beim Speichern abgelehnt.
Der Rumpf landet eine Ebene tief, als Text, Zahl oder Wahrheitswert. Unbekannte Felder werden übersprungen statt abgelehnt, und das ist die einzige Stelle, an der sich eine Webhook-Nutzlast von einer selbst geschriebenen unterscheidet: Eine handgeschriebene Nutzlast mit einem Tippfehler soll scheitern, aber die Nachricht eines Dritten enthält leere Werte, verschachtelte Objekte und Listen, weil sie nie für uns geschrieben wurde, und die ganze Anfrage wegen eines zusätzlichen Feldes abzulehnen macht jede echte Quelle unbrauchbar.
Was wir aufschreiben würden
- Ist die URL der Schlüssel, dann einen Schlüssel daraus machen. Zufällig, nicht abgeleitet, nicht zu erraten, widerrufbar, nie erneut vergeben.
- Ein Geheimnis, mit dem gerechnet wird, lässt sich nicht hashen. Verschlüsseln, an seinen Eigentümer binden, geschlossen scheitern, wenn es sich nicht öffnet.
- Die Uhr signieren, nicht nur den Rumpf. Ein Zeitstempel außerhalb der Signatur ist ein Schutz, der nichts schützt.
- Eine Größengrenze durchsetzen, während die Bytes eintreffen. Eine Prüfung nach dem Lesen hat den Preis bereits bezahlt.
- Bremsen, was der Kunde eingestellt hat, nicht was ein Angreifer steuert.
- Die Ablehnungen protokollieren, nicht die Nutzlast. Ablehnungen machen „es passiert nichts“ beantwortbar; die Nutzlast gehört einem anderen.
- Hat ein neuer Fall kein Subjekt, jede Liste suchen, die „alle Fälle“ meinte. Eine davon ist ein Synonym für die Aufzählung, und sie sagt es nicht.
Zur Klarheit: Die Endpunkte, die Verfahren, die Belege und der Webhook-Auslöser sind gebaut und laufen. Unbewiesen sind der Rückfall der Durchsatzbremse bei einem echten Cacheausfall, das Verhalten nach einem gewechselten Plattformgeheimnis und all das auf einer eigenen Domain eines Kunden.
Aus der Plattformarbeit an Provibr. Wie der Graph hinter dem Auslöser endlich gehalten wird, steht in ein Flow ist ein Graph, und er muss endlich sein, und wie die Arbeitsfläche ihren Draht bekam, steht in ein Draht zum Ziehen, und genau ein Auslöser.