Блог ·
Адреса вебхука, яка і є секретом
Вхідний вебхук може бути власним ключем, якщо адреса витримує цю вагу. Ціна: 160 бітів, підпис над годинником і тіло, обрізане просто під час читання.
Автор: Provibr
Хтось хоче, щоб його засіб моніторингу запускав один із наших потоків автоматизації. Той засіб уміє надсилати JSON на адресу, і більше нічого: без підпису, без власного заголовка, без клієнтського сертифіката. Це коробка в стійці, налаштована 2019 року, і нічого нового вона вже не навчиться.
Можна відмовити такому клієнтові або погодитися, що для деяких джерел сама адреса є ключем. Ми погодилися, а потім витратили цілий раунд на те, щоб ця адреса справді витримувала таку вагу, а не просто була довгою.
Тридцять два символи, ніколи ні з чого не виведені
Ідентифікатор у /api/hooks/flows/<slug> має тридцять два символи з абетки на тридцять два знаки: 160 бітів сирої ентропії. Він не виводиться ні з облікового запису, ні з потоку, ні з назви точки входу, бо ідентифікатор, який можна обчислити з того, що клієнт уже оприлюднив, може обчислити кожен, хто читає його сторінку стану.
Абеткою є base32 Крокфорда: цифри та малі літери без i, l, o та u. Той самий вибір, що й для наших кодів активації агентів, з тієї самої причини. Цей рядок передруковують у чужій панелі керування, і жоден шрифт не робить l і 1 розрізненними, коли людина втомлена.
Секрет шифрують, а не хешують
Точка входу може ще нести спільний секрет у вигляді підпису HMAC або токена bearer. Обидва зберігаються зашифрованими, і це варте пояснення, бо тут інстинкт, вихований двома десятиліттями порад про паролі, є цілком хибним.
Пароль можна захешувати, бо відповісти треба лише на те, чи це той самий рядок. Із секретом HMAC так не вийде: перевірка означає самостійно обчислити підпис, а для цього потрібен відкритий текст. Тож він потрапляє всередину під AES-256-GCM, з ключем, виведеним із секрету платформи, і з автентифікованими даними, які називають точку входу, якій він належить, аби шифротекст, узятий з одного рядка, не перевірявся як секрет іншого. Це та сама механіка, що вже захищає платіжні ключі та секрети сценаріїв; другий механізм означав би другу історію ротації та друге місце, де можна помилитися.
Якщо джерело ключа колись зміниться, шифротекст не відкриється, і запит буде відхилено, а не прийнято; екран тоді каже, що секрет треба згенерувати заново. Відмовляти навстіж перед нечитабельним секретом є тим рядком, який пишуть один раз поспіхом і знаходять через три роки.
Клієнт бачить секрет один раз, під час створення. Не тому, що ми не могли б показати його вдруге, а тому, що «записано вже у двох місцях» і «видно кожному з правами автоматизації» є різними ризиками, і лише перший є неминучим.
Годинник входить у підписаний матеріал
Підписом є sha256=<hex> у заголовку, обчислений над позначкою часу, крапкою і сирим тілом. Позначка часу мандрує у власному заголовку і має лежати не далі ніж за пʼять хвилин від нашого годинника, з обох боків.
Покласти позначку часу всередину підписаного матеріалу, а не поруч із ним, і є всім захистом від повтору. Якщо підписувати саме тіло, кожен, хто перехопив один чинний запит, зможе повторювати його вічно зі свіжим заголовком часу, а вікно залишиться прикрасою. Обидва краї теж важать: перевірка, яка відкидає лише надто старе, приймає підпис, датований 2099 роком.
Порівняння виконується за сталий час, бо звичайна рівність над шістнадцятковим текстом уривається на першому відмінному символі, і цього часу вистачає, щоб вгадати підпис байт за байтом. А позначку часу оцінюють після того, як підпис збігся, ніколи раніше. Навпаки «прострочено» було б безкоштовним оракулом: людина взагалі без чинного секрету дізналася б, що річ у її годиннику, а не в її підписі. Різниця між хибним і простроченим підписом таки існує, але вона існує в квитанції, яку читає власник точки входу, а не у відповіді, яку отримує той, хто звертається.
Тіло, обрізане під час надходження
Стеля тіла типово становить 64 КіБ, і важить не число, а місце, де її застосовують.
Прочитати весь запит у рядок, а вже потім перевірити довжину означає перевірку після шкоди: тіло на 500 МБ коштує нам 500 МБ ще до відмови, і стеля перетворюється на запрошення. Тому тіло читають шматками з потоку, поточну суму порівнюють на кожному шматку, а читача скасовують, щойно вона переходить межу. Записаними байтами є ті, які ми справді прочитали, а не Content-Length, оголошений тим, хто звертається, бо саме тому, хто нас атакує, і не варто вірити заголовок.
Гальмо потоку стоїть після тіла, а не перед ним, усупереч звичній пораді, і так і має бути: підпис обчислюють над тілом, тож гальмо, яке відповідає раніше, не може знати, чи був запит законним. Воно рахує на точку входу, а не на адресу джерела. Справжнє джерело вебхуків має одну адресу; той, хто атакує, має тисячу, тож гальмо на адресі захищає саме не ту сторону.
Девʼять відмов, дванадцять наслідків і ніде тіла
Порядок відмов і є задумом. Спершу форма ідентифікатора, ще до звернення до бази, бо where slug = <мегабайт тексту> є роботою, до якої незнайомець не має змоги нас примусити. Далі існування, відкликання, вимкнення, перелік адрес, стеля тіла, гальмо потоку, підпис і врешті те, чи JSON узагалі є обʼєктом.
Кожна відмова, починаючи з третього кроку, пише квитанцію. Ця таблиця і є відповіддю на «мій вебхук нічого не робить», фразу, що ховає дві дуже різні біди: нічого не надходить або щось надходить і ми це відхиляємо. Без рядка їх не розрізнити, і клієнт витрачає пообіддя не на ту.
Квитанція несе наслідок окремим словом поруч зі станом HTTP, бо коди стану для цього є поганою мовою: одна й та сама 403 покриває і «вашої адреси немає в переліку», і «ця точка входу вимкнена», а це відсилає людину на два різні екрани. Дванадцять наслідків, лише додаванням, з адресою джерела та прочитаними байтами.
Квитанція ніколи не несе тіла: це чуже корисне навантаження, у ньому може бути будь-що, і зручність під час пошуку помилки не варта таблиці, яка мовчки збирає чужі дані. Квитанції зберігають девʼяносто днів, свідомо менше за виконання, до яких вони належать, бо адреса джерела є персональними даними третьої особи, яка ні на що не погоджувалася.
Одна відмова не пише нічого: невідомий ідентифікатор. Немає облікового запису, до якого причепити рядок, а таблиця, що росте щоразу, коли незнайомець вгадує, є безкоштовним способом наповнити нашу базу.
Чому 410 і 404 не є однією відповіддю
Відкликання точки входу її не видаляє. Рядок лишається, ідентифікатор лишається зайнятим назавжди, а запит отримує 410 замість 404. Назва знову стає вільною, бо розрізняти дві живі точки входу було єдиним сенсом тієї унікальності, але адреса не повертається ніколи.
Спокуслива альтернатива полягає в тому, щоб відповідати 404 в обох випадках, щоб ніхто не дізнався, які ідентифікатори колись існували. Вгадати ідентифікатор коштує 160 бітів роботи, тож той, хто його має, його отримав: із рядка журналу, зі старої інтеграції або зі знімка екрана, і давно знає, що він існував. Ця різниця йому нічого не каже.
Чесній половині світу вона каже чимало. Джерело, яке після ротації далі надсилає на стару адресу, бачить із 410, що йому потрібна нова; перед 404 воно робить висновок, що хтось помилився в шляху, і тоді людина шукає ваду в шляху, а не в ротації.
Перший тригер без сервера
Кожен попередній тригер працював над однією наявною машиною: підготовленою, перевстановленою, видаленою. Виконання через вебхук не має машини, ані тарифу, ані адреси, ані інтеграції, ані агента, ані нікого з відкритим сеансом.
Звучить дрібно, а виявилося найгострішим краєм раунду. Каталог змінних позначає в кожній групі тригери, для яких вона доступна, а стала зі значенням «завжди» була буквально переліком усіх тригерів. Пʼяте значення отже запропонувало б потокові через вебхук усі змінні сервера, тарифу, адреси, мережі та агента у вибиральнику; валідатор схвалив би їх, а перший справжній запит упав би на етапі відтворення тексту, всередині виконання, за яким ніхто не спостерігає.
Тепер отже існує окремий перелік, що відповідає на інше питання: чи працює цей тригер над сервером? Шостий тригер муситиме відповісти на нього першим. Те, чого бракує виконанню через вебхук, вирізають із контексту, а не лишають наявним і порожнім, а вузол дії в такому графі відхиляють під час збереження.
Тіло приземляється на один рівень, як текст, число або логічне значення. Невідомі поля пропускають, а не відхиляють, і це єдине місце, де корисне навантаження вебхука відрізняється від написаного самим клієнтом: написане вручну навантаження з одруком має падати, але повідомлення третьої сторони містить порожні значення, вкладені обʼєкти та переліки, бо його ніколи не писали для нас, і відхилення цілого запиту через одне зайве поле робить непридатним будь-яке справжнє джерело.
Що ми записали б
- Якщо адреса є ключем, то й зробіть із неї ключ. Випадковий, невиведений, невгадуваний, відкличний, ніколи не виданий удруге.
- Секрет, яким ви обчислюєте, захешувати не можна. Зашифруйте його, привʼяжіть до власника і відмовляйте наглухо, коли він не відкривається.
- Підписуйте годинник, а не саме тіло. Позначка часу поза підписом є захистом, що нічого не захищає.
- Застосовуйте стелю розміру, поки байти надходять. Перевірка після читання вже заплатила ціну.
- Гальмуйте те, що налаштував клієнт, а не те, чим керує нападник.
- Записуйте відмови, а не навантаження. Відмови роблять «нічого не відбувається» питанням із відповіддю; навантаження є чужими даними.
- Якщо в нового випадку немає підмета, шукайте кожен перелік, що означав «усі випадки». Один із них є синонімом переліку значень і про це не каже.
Для ясності: точки входу, схеми, квитанції та тригер вебхука побудовані й працюють. Недоведеними лишаються запасний варіант гальма в памʼяті під час справжнього збою кешу, поведінка після зміни секрету платформи і все це на власному домені клієнта.
Із платформної роботи над Provibr. Як граф за тригером лишається скінченним, описано в потік є графом, а граф має бути скінченним, а як полотно дістало свій дріт, описано в дріт, який ви тягнете, і рівно один тригер.