Naar de inhoud

Blog ·

Een webhook-URL die het geheim is

Een inkomende webhook kan zijn eigen sleutel zijn als de URL dat gewicht draagt. Wat dat kost: 160 bits, een handtekening over de klok, een afgekapte body.

Door Provibr

Iemand wil dat zijn monitoringtool een van onze automatiseringsflows start. Die tool kan JSON naar een URL posten, en dat is alles: geen ondertekening, geen eigen kop, geen clientcertificaat. Het is een kast in een rack, ingericht in 2019, en hij gaat niets nieuws meer leren.

Je kunt die klant weigeren, of aanvaarden dat de URL bij sommige bronnen zélf de sleutel is. Wij hebben aanvaard, en daarna de hele ronde besteed aan de vraag of die URL dat gewicht ook werkelijk draagt in plaats van alleen lang te zijn.

Tweeëndertig tekens, en nooit ergens uit afgeleid

De slug in /api/hooks/flows/<slug> is tweeëndertig tekens uit een alfabet van tweeëndertig symbolen: 160 bits ruwe entropie. Hij is niet afgeleid van het account, de flow of de naam van het endpoint, want een slug die te berekenen valt uit dingen die een klant al gepubliceerd heeft, is te berekenen door iedereen die zijn statuspagina leest.

Het alfabet is Crockford-base32: de cijfers en de kleine letters min i, l, o en u. Dezelfde keuze als bij onze activatiecodes voor agents, en om dezelfde reden. Deze tekst wordt overgetypt in het beheerpaneel van iemand anders, en geen enkel lettertype maakt l en 1 uit elkaar te houden voor wie moe is.

Het geheim staat versleuteld, niet gehasht

Endpoints kunnen daarnaast een gedeeld geheim dragen, als HMAC-handtekening of als bearer-token. Allebei worden ze versleuteld bewaard, en dat is uitleg waard, want het instinct dat twee decennia wachtwoordadvies heeft opgeleverd is hier precies verkeerd.

Een wachtwoord kan gehasht worden omdat je alleen ooit hoeft te beantwoorden of het dezelfde tekst is. Een HMAC-geheim kan dat niet: verifiëren betekent de handtekening zelf uitrekenen, en daarvoor heb je de klare tekst nodig. Het gaat dus onder AES-256-GCM naar binnen, met een sleutel die van het platformgeheim is afgeleid en met geauthenticeerde gegevens die het endpoint benoemen waar het bij hoort, zodat een ciphertext uit de ene rij niet als het geheim van een andere rij geverifieerd kan worden. Het is dezelfde machinerie die de betaalsleutels en de scriptgeheimen al beschermt; een tweede mechanisme zou een tweede rotatieverhaal betekenen en een tweede plek om het mis te hebben.

Wisselt die sleutelbron ooit, dan opent de ciphertext niet en wordt het verzoek geweigerd in plaats van aanvaard; het scherm zegt dan dat het geheim opnieuw gegenereerd moet worden. Open falen op een onleesbaar geheim is het soort ding dat je één keer in haast schrijft en drie jaar later terugvindt.

De klant ziet het geheim één keer, bij het aanmaken. Niet omdat wij het niet opnieuw kúnnen tonen, maar omdat "staat nu op twee plekken opgeschreven" en "zichtbaar voor iedereen met automatiseringsrechten" verschillende risico's zijn, en alleen het eerste onvermijdelijk is.

De klok gaat ín het getekende materiaal

De handtekening is sha256=<hex> in een kop, over het tijdstip, een punt, en de rauwe body. Het tijdstip reist in een eigen kop en moet binnen vijf minuten van onze klok liggen, aan beide kanten.

Het tijdstip ín het getekende materiaal zetten in plaats van ernaast is het hele anti-replaymechanisme. Teken je alleen de body, dan kan iedereen die één geldig verzoek onderschepte het voor altijd herhalen met een verse tijdstipkop, en is het venster decoratie. Beide kanten tellen bovendien: een controle die alleen "te oud" weigert, aanvaardt een handtekening met 2099 erop.

De vergelijking loopt in constante tijd, want een gewone gelijkheidstoets op hex-tekst stopt bij het eerste afwijkende teken, en die timing is genoeg om een handtekening byte voor byte te raden. En het tijdstip wordt pas beoordeeld nadat de handtekening klopt, nooit ervoor. Andersom zou "verlopen" een gratis orakel zijn: iemand zonder enig geldig geheim zou eruit leren dat zijn klok het probleem was en niet zijn handtekening. Het onderscheid tussen een foute en een verlopen handtekening bestaat wel degelijk, maar het bestaat in het receipt dat de eigenaar van het endpoint leest, niet in het antwoord dat de beller krijgt.

Een body die wordt afgekapt terwijl hij binnenkomt

De bodygrens staat standaard op 64 KiB, en wat telt is niet het getal maar de plek waar hij afgedwongen wordt.

Het verzoek eerst in een tekst inlezen en dan de lengte toetsen is een controle ná de schade: een body van 500 MB kost ons 500 MB vóór de weigering, en dan is de grens een uitnodiging. De body wordt daarom in stukken uit de stroom gelezen, het lopende totaal wordt bij elk stuk vergeleken, en de lezer wordt geannuleerd zodra het eroverheen gaat. De vastgelegde bytes zijn de bytes die wij werkelijk lazen, niet de Content-Length die de beller beweerde, want een beller die ons aanvalt is precies degene wiens kop wij niet moeten geloven.

De doorvoerrem staat ná de body en niet ervoor, wat het omgekeerde is van het gebruikelijke advies en ook zo moet: de handtekening wordt over de body berekend, dus een rem die eerder antwoordt kan niet weten of dit verzoek legitiem was. Hij telt per endpoint en niet per bronadres. Een echte webhookbron heeft één adres; wie kwaad wil heeft er duizend, dus een rem op het adres beschermt precies de verkeerde partij.

Negen weigeringen, twaalf uitkomsten, en nergens een body

De volgorde van weigeren ís het ontwerp. Eerst de vorm van de slug, vóór de database geraakt wordt, want where slug = <een megabyte tekst> is werk dat een onbekende ons niet hoort te kunnen laten doen. Daarna bestaan, ingetrokken, uitgeschakeld, de adres-allowlist, de bodygrens, de doorvoerrem, de handtekening, en ten slotte of de JSON überhaupt een object is.

Elke weigering vanaf de derde stap schrijft een receipt. Die tabel is het antwoord op "mijn webhook doet niets", een zin die twee heel verschillende problemen verbergt: er komt niets binnen, of er komt iets binnen dat wij afkeuren. Zonder rij zijn die twee niet te onderscheiden en besteedt de klant zijn middag aan het verkeerde.

Het receipt draagt de uitkomst als eigen woord naast de HTTP-status, want statuscodes zijn daar een slechte taal voor: één 403 dekt zowel "je adres staat niet op de lijst" als "dit endpoint staat uit", en die sturen iemand naar twee verschillende schermen. Twaalf uitkomsten, append-only, met het bronadres en de gelezen bytes.

Het receipt draagt nooit de body: dat is de payload van iemand anders, er kan van alles in staan, en een gemak bij het zoeken naar een fout weegt niet op tegen een tabel die stilletjes andermans gegevens verzamelt. Receipts blijven negentig dagen staan, bewust korter dan de uitvoeringen waar ze bij horen, want een bronadres is een persoonsgegeven van een derde die nergens mee heeft ingestemd.

Eén weigering schrijft niets: een onbekende slug. Er is geen account om de rij aan te hangen, en een tabel die groeit zodra een onbekende gokt is een gratis manier om onze database te vullen.

Waarom 410 en 404 niet hetzelfde antwoord zijn

Een endpoint intrekken verwijdert hem niet. De rij blijft staan, de slug blijft voor altijd bezet, en een verzoek krijgt 410 in plaats van 404. De naam wordt weer bruikbaar, want twee levende endpoints uit elkaar houden was het enige waar die uniciteit voor bestond, maar de URL komt nooit meer terug.

Het verleidelijke alternatief is om op allebei 404 te antwoorden, zodat niemand leert welke slugs ooit bestaan hebben. Een slug raden is 160 bits werk, dus wie er een in handen heeft, heeft hem gekregen, van een logregel of een oude integratie of een schermafdruk, en weet allang dat hij bestond. Het onderscheid vertelt hem niets.

Het vertelt de eerlijke helft van de wereld nogal wat. Een bron die na een rotatie op de oude URL blijft posten, ziet aan een 410 dat hij een nieuwe nodig heeft; aan een 404 concludeert hij dat iemand het pad verkeerd typte, en dan gaat er een mens een fout zoeken in het pad in plaats van in de rotatie.

De eerste trigger zonder server

Elke eerdere trigger draaide tegen één bestaande machine: uitgerold, opnieuw geïnstalleerd, verwijderd. Een webhookrun heeft geen machine, geen plan, geen adres, geen integratie, geen agent en niemand die is ingelogd.

Dat klinkt klein en het was de scherpste rand van de ronde. De variabelencatalogus zet bij elke groep de triggers waarvoor hij beschikbaar is, en de constante die "altijd" betekende was letterlijk de lijst met alle triggers. Een vijfde waarde had een webhookflow dus elke server-, plan-, adres-, netwerk- en agentvariabele in de kiezer aangeboden; de validator had ze goedgekeurd, en het eerste echte verzoek was omgevallen bij het renderen, in een uitvoering waar niemand naar kijkt.

Er is nu dus een aparte lijst die een andere vraag beantwoordt: draait deze trigger tegen een server. Een zesde trigger moet die vraag eerst beantwoorden. Wat een webhookrun mist wordt uit de context weggesnoeid in plaats van aanwezig en leeg gelaten, en een actieknoop in zo'n graaf wordt bij het opslaan geweigerd.

De body landt één niveau diep, als tekst, getal of booleaanse waarde. Onbekende velden worden overgeslagen in plaats van geweigerd, en dat is de enige plek waar een webhookpayload verschilt van een payload die de klant zelf schreef: een handgeschreven payload met een tikfout hoort te falen, maar het bericht van een derde bevat lege waarden, geneste objecten en arrays omdat het nooit voor ons geschreven is, en het hele verzoek afkeuren om één extra veld maakt elke echte bron onbruikbaar.

Wat wij zouden opschrijven

  1. Is de URL de sleutel, maak er dan een sleutel van. Willekeurig, niet afgeleid, niet te raden, in te trekken, nooit opnieuw uitgegeven.
  2. Een geheim waarmee je rekent kan niet gehasht worden. Versleutel het, bind het aan zijn eigenaar, en faal gesloten als het niet opent.
  3. Teken de klok, niet alleen de body. Een tijdstip buiten de handtekening is een anti-replaymaatregel die niets tegenhoudt.
  4. Dwing een grootte-grens af terwijl de bytes binnenkomen. Een controle ná het lezen heeft de prijs al betaald.
  5. Rem wat de klant instelde, niet wat een aanvaller bestuurt.
  6. Log de weigeringen, niet de payload. Weigeringen maken "er gebeurt niets" beantwoordbaar; de payload is andermans gegeven.
  7. Heeft een nieuw geval geen onderwerp, zoek dan elke lijst die "alle gevallen" bedoelde. Eén ervan is een synoniem voor de opsomming, en hij zegt dat niet.

Voor de duidelijkheid: de endpoints, de schema's, de receipts en de webhooktrigger zijn gebouwd en draaien. Onbewezen zijn de val-terug van de doorvoerrem bij een echte cachestoring, het gedrag na een gewisseld platformgeheim, en dit alles op een eigen domein van een klant.

Uit het platformwerk aan Provibr. Hoe de graaf achter de trigger eindig wordt gehouden staat in een flow is een graaf, en een graaf moet eindig zijn, en hoe het canvas zijn draad kreeg staat in een draad die je sleept, en precies één trigger.

Begin met één host en één agent

Maak een account aan, installeer de agent met één commando en koppel je eerste Proxmox-host. De proefperiode loopt 14 dagen en vraagt geen betaalgegevens.