Blog ·
Un URL di webhook che è il segreto
Un webhook in entrata può essere la propria credenziale se l’URL ne regge il peso. Il costo: 160 bit, una firma sull’orologio e un corpo troncato subito.
Di Provibr
Qualcuno vuole che il proprio strumento di monitoraggio avvii uno dei nostri flussi di automazione. Quello strumento sa inviare JSON a un URL, e nient’altro: nessuna firma, nessuna intestazione propria, nessun certificato client. È un apparato in un rack, configurato nel 2019, e non imparerà più nulla di nuovo.
Si può rifiutare quel cliente, oppure accettare che per certe sorgenti sia l’URL stesso la credenziale. Abbiamo accettato, e poi dedicato l’intera tornata a verificare che quell’URL regga davvero quel peso invece di limitarsi a essere lungo.
Trentadue caratteri, e mai derivati da qualcosa
L’identificativo in /api/hooks/flows/<slug> è di trentadue caratteri presi da un alfabeto di trentadue simboli: 160 bit di entropia grezza. Non è derivato dall’account, dal flusso né dal nome del punto di ingresso, perché un identificativo calcolabile da ciò che un cliente ha già pubblicato è calcolabile da chiunque ne legga la pagina di stato.
L’alfabeto è il base32 di Crockford: le cifre e le minuscole meno i, l, o e u. La stessa scelta dei nostri codici di attivazione degli agent, per la stessa ragione. Questa stringa viene ribattuta nel pannello di amministrazione di qualcun altro, e nessun carattere tipografico rende l e 1 distinguibili quando si è stanchi.
Il segreto si cifra, non si sintetizza
Un punto di ingresso può inoltre portare un segreto condiviso, come firma HMAC o come token bearer. Entrambi vengono conservati cifrati, e la cosa merita una spiegazione, perché l’istinto costruito da vent’anni di consigli sulle password qui è esattamente sbagliato.
Una password si può sintetizzare perché basta rispondere se si tratta della stessa stringa. Un segreto HMAC no: verificare significa calcolare la firma, e per farlo serve il testo in chiaro. Entra dunque sotto AES-256-GCM, con una chiave derivata dal segreto della piattaforma e con dati autenticati che nominano il punto di ingresso a cui appartiene, così che un testo cifrato prelevato da una riga non si verifichi come segreto di un’altra. È la stessa meccanica che già protegge le chiavi di pagamento e i segreti degli script; un secondo meccanismo significherebbe una seconda storia di rotazione e un secondo luogo in cui sbagliare.
Se quella sorgente di chiave dovesse cambiare, il testo cifrato non si apre e la richiesta viene rifiutata anziché accettata; la schermata dice allora che il segreto va rigenerato. Fallire in apertura davanti a un segreto illeggibile è il genere di riga che si scrive una volta di fretta e si ritrova tre anni dopo.
Il cliente vede il segreto una volta sola, alla creazione. Non perché non si possa mostrare di nuovo, ma perché «annotato ormai in due posti» e «visibile a chiunque abbia i permessi di automazione» sono rischi diversi, e solo il primo è inevitabile.
L’orologio entra nel materiale firmato
La firma è sha256=<hex> in un’intestazione, calcolata su marca temporale, un punto e il corpo grezzo. La marca viaggia in un’intestazione propria e deve cadere entro cinque minuti dal nostro orologio, da entrambi i lati.
Mettere la marca dentro il materiale firmato invece che accanto è l’intero dispositivo contro la ripetizione. Se si firma solo il corpo, chiunque abbia intercettato una richiesta valida può ripeterla per sempre con un’intestazione temporale fresca, e la finestra diventa un ornamento. Contano entrambi i bordi: un controllo che rifiuta solo il troppo vecchio accetta una firma datata 2099.
Il confronto avviene a tempo costante, perché un test di uguaglianza ordinario su testo esadecimale si ferma al primo carattere diverso, e quel tempo basta a indovinare una firma byte per byte. E la marca viene giudicata dopo che la firma torna, mai prima. Al contrario, «scaduta» sarebbe un oracolo gratuito: qualcuno senza alcun segreto valido apprenderebbe che il problema era il suo orologio e non la sua firma. La distinzione fra firma errata e firma scaduta esiste eccome, ma esiste nella ricevuta che legge il proprietario del punto di ingresso, non nella risposta data al chiamante.
Un corpo troncato mentre arriva
Il limite del corpo vale 64 KiB per impostazione predefinita, e ciò che conta non è il numero ma il punto in cui viene applicato.
Leggere la richiesta intera in una stringa e poi controllarne la lunghezza è un controllo successivo al danno: un corpo da 500 MB ci costa 500 MB prima del rifiuto, e il limite diventa un invito. Il corpo viene perciò letto a blocchi dal flusso, il totale corrente viene confrontato a ogni blocco, e il lettore viene annullato appena lo supera. I byte registrati sono quelli letti davvero, non la Content-Length dichiarata dal chiamante, perché chi ci attacca è proprio colui la cui intestazione non conviene credere.
Il freno di portata sta dopo il corpo e non prima, al contrario del consiglio abituale, e così deve essere: la firma si calcola sul corpo, quindi un freno che risponde prima non può sapere se la richiesta era legittima. Conta per punto di ingresso e non per indirizzo di origine. Una vera sorgente di webhook ha un indirizzo; chi attacca ne ha mille, perciò un freno sull’indirizzo protegge esattamente la parte sbagliata.
Nove rifiuti, dodici esiti e nessun corpo da nessuna parte
L’ordine dei rifiuti è il progetto. Prima la forma dell’identificativo, prima di toccare la base dati, perché where slug = <un megabyte di testo> è lavoro che uno sconosciuto non dovrebbe poterci imporre. Poi l’esistenza, la revoca, la disattivazione, l’elenco di indirizzi, il limite del corpo, il freno di portata, la firma e infine se il JSON sia perlomeno un oggetto.
Ogni rifiuto dal terzo passo in poi scrive una ricevuta. Quella tabella è la risposta a «il mio webhook non fa nulla», una frase che nasconde due problemi molto diversi: non arriva niente, oppure arriva qualcosa e lo respingiamo. Senza una riga i due sono indistinguibili, e il cliente passa il pomeriggio su quello sbagliato.
La ricevuta porta l’esito come parola propria accanto allo stato HTTP, perché i codici di stato sono una lingua scadente per questo: un solo 403 copre sia «il suo indirizzo non è in elenco» sia «questo punto di ingresso è spento», e le due cose mandano una persona su due schermate diverse. Dodici esiti, in sola aggiunta, con l’indirizzo di origine e i byte letti.
La ricevuta non porta mai il corpo: è il carico di qualcun altro, può contenere qualsiasi cosa, e una comodità di diagnosi non vale una tabella che accumula in silenzio dati altrui. Le ricevute restano novanta giorni, deliberatamente meno delle esecuzioni a cui appartengono, perché un indirizzo di origine è un dato personale di un terzo che non ha acconsentito a nulla.
Un rifiuto non scrive nulla: un identificativo sconosciuto. Non c’è un account a cui appendere la riga, e una tabella che cresce ogni volta che uno sconosciuto tira a indovinare è un modo gratuito di riempire la nostra base dati.
Perché 410 e 404 non sono la stessa risposta
Revocare un punto di ingresso non lo cancella. La riga resta, l’identificativo resta occupato per sempre, e una richiesta riceve 410 invece di 404. Il nome torna disponibile, perché distinguere due punti di ingresso vivi era tutto lo scopo di quell’unicità, ma l’URL non torna mai.
L’alternativa allettante è rispondere 404 in entrambi i casi, così che nessuno scopra quali identificativi siano esistiti. Indovinarne uno sono 160 bit di lavoro, dunque chi ne possiede uno lo ha ricevuto, da una riga di registro, da una vecchia integrazione o da una schermata, e sa già che esisteva. La distinzione non gli dice nulla.
Alla metà onesta del mondo dice parecchio. Una sorgente che continua a inviare al vecchio URL dopo una rotazione vede dal 410 che gliene serve uno nuovo; davanti a un 404 conclude che qualcuno ha digitato male il percorso, e allora una persona cerca un difetto nel percorso anziché nella rotazione.
Il primo attivatore senza server
Ogni attivatore precedente agiva su una macchina esistente: predisposta, reinstallata, eliminata. Un’esecuzione da webhook non ha macchina, né piano, né indirizzo, né integrazione, né agent, né alcuno collegato.
Sembra poca cosa ed è stato lo spigolo più tagliente della tornata. Il catalogo delle variabili annota per ogni gruppo gli attivatori per cui è disponibile, e la costante che significava «sempre» era letteralmente l’elenco di tutti gli attivatori. Un quinto valore avrebbe dunque offerto a un flusso da webhook ogni variabile di server, piano, indirizzo, rete e agent nel selettore; il validatore le avrebbe approvate, e la prima richiesta vera sarebbe caduta in fase di resa, dentro un’esecuzione che nessuno guarda.
Ora esiste quindi un elenco separato che risponde a un’altra domanda: questo attivatore agisce su un server? Un sesto attivatore dovrà rispondere prima a quella. Ciò che manca a un’esecuzione da webhook viene potato dal contesto anziché lasciato presente e vuoto, e un nodo di azione in un grafo simile viene rifiutato al salvataggio.
Il corpo atterra a un solo livello, come testo, numero o valore logico. I campi sconosciuti vengono saltati anziché rifiutati, ed è l’unico punto in cui un carico da webhook differisce da uno scritto dal cliente stesso: un carico scritto a mano con un refuso deve fallire, ma il messaggio di un terzo contiene valori nulli, oggetti annidati ed elenchi perché non è mai stato scritto per noi, e rifiutare l’intera richiesta per un campo in più rende inservibile qualunque sorgente reale.
Che cosa annoteremmo
- Se l’URL è la credenziale, conviene farne una credenziale. Casuale, non derivata, non indovinabile, revocabile, mai riemessa.
- Un segreto con cui si calcola non si può sintetizzare. Cifrarlo, legarlo al suo proprietario, fallire in chiusura quando non si apre.
- Firmare l’orologio, non solo il corpo. Una marca temporale fuori dalla firma è una difesa che non difende nulla.
- Applicare il limite di dimensione mentre i byte arrivano. Un controllo dopo la lettura ha già pagato il prezzo.
- Frenare ciò che ha impostato il cliente, non ciò che governa chi attacca.
- Registrare i rifiuti, non il carico. I rifiuti rendono «non succede nulla» una domanda con risposta; il carico è dato altrui.
- Se un caso nuovo non ha soggetto, cercare ogni elenco che significava «tutti i casi». Uno di essi è sinonimo dell’enumerazione, e non lo dichiara.
Per chiarezza: i punti di ingresso, gli schemi, le ricevute e l’attivatore webhook sono costruiti e funzionanti. Restano non dimostrati il ripiego in memoria del freno durante un guasto reale della cache, il comportamento dopo un cambio del segreto di piattaforma, e tutto ciò sul dominio proprio di un cliente.
Dal lavoro di piattaforma su Provibr. Come il grafo dietro l’attivatore resta finito si trova in un flusso è un grafo, e un grafo dev’essere finito, e come la tela ha ottenuto il suo filo in un filo che si trascina, ed esattamente un attivatore.