Моя форма реєстрації стала способом спамити чужу пошту
Привіт ще раз, спільното. Мене звати Валентин, я розробник. У своїй минулій статті я розповідав свою історію, а на вихідних сталось щось новеньке, і я вирішив цим поділитися.
Півдня шукав баг у власній реєстрації, якого в результаті не знайшов. Натомість знайшов іншу проблему, про яку раніше взагалі не думав.
Буде корисно всім, у кого є форма реєстрації або «забули пароль». Тобто майже всім.
Як це виглядало
Відкрив адмінку, побачив три нові реєстрації. Для продукту з десятком користувачів це вже подія, тому я пішов подивитись, хто прийшов.
Усі три з’явились протягом кількох хвилин. Адреси — на вигляд живі, не з одноразових поштовиків (фільтр на disposable-домени в мене стоїть давно, близько трьох з половиною тисяч доменів, і він їх пропустив). Ніхто з трьох не підтвердив пошту й не створив жодного монітора.
А далі те, що й змусило придивитись: одразу після кожної реєстрації — запит на скидання пароля. Не для щойно створеного акаунта. На зовсім іншу адресу, яка до цих трьох не мала жодного стосунку.
Чому я спершу подумав на себе
Перша думка була проста: у мене баг. Щось у флоу реєстрації неправильно зв’язує акаунт із адресою, і тому ресет летить не туди.
Я сів і почав розбирати ланцюг: що пишеться в базу при реєстрації, як `forgot-password` знаходить користувача, чи не може там десь підхопитись не той рядок. Витратив на це більшу частину дня.
Нічого зламаного не було. Код робив те, що я йому написав.
Що це було насправді
Моя форма реєстрації надішле лист на будь-яку адресу, яку в неї вписати. Моя форма «забули пароль» зробить те саме. Обидві — стільки разів, скільки попросять, без жодних питань.
Будь-хто міг вписати туди чужу адресу і змусити мій сервер відправити на неї лист. Потім ще один. І ще.
Ціллю був не я, а людина, чию адресу вписували в поле. Мій продукт просто став безкоштовним ретранслятором. Зручно: нічого не питає, а домен молодий і ще ні в кого не в чорних списках.
Я не маю жодного уявлення, хто це робив і навіщо саме ця адреса. І не буду вигадувати: у логах видно лише механіку, а не мотив.
Скільки це коштує
Від менш неприємного до більш неприємного.
- Квота поштового провайдера
Найочевидніше і найменш важливе. Так, кожен такий лист списується з мого ліміту. Це просто гроші, і в моїх масштабах — невеликі.
- Репутація домену-відправника
Ось тут вже цікавіше. Листи, яких людина не замовляла, летять у спам або відбиваються. Поштові провайдери це запам’ятовують, і запам’ятовують на рівні домену.
А тепер дивимось, що саме в мене на цьому домені. З нього йдуть листи «ваш сервіс лежить». Це не маркетингова розсилка, яку не шкода. Це те, заради чого продукт існує: клієнт платить мені за те, щоб цей лист дійшов о третій ночі й дійшов швидко.
Тобто проблема у формі реєстрації може поступово зіпсувати доставку алертів. А дізнатись про це можна вже тоді, коли хтось не отримає попередження про падіння.
Для сервісу моніторингу це досить поганий сценарій.
Перше, що я зробив — інвентаризацію
Я не став одразу закривати тільки те, що побачив. Спершу виписав усі ендпоінти, які можуть надіслати лист на адресу, введену кимось без автентифікації.
Вийшло чотири:
— реєстрація
— забули пароль
— надіслати лист підтвердження ще раз
— підписка на сторінку статусу
Два останні я обмежив ще давно — не тому, що розумний, а тому, що колись уже думав про них у контексті спаму. Два перші, найочевидніші, стояли відкриті.
Раджу просто зробити такий список окремо. Не шукати одразу «де в мене діра», а подивитись, звідки сервер взагалі може надіслати листа на адресу, яку ввів користувач. У мене список виявився довшим, ніж я думав.
Ліміт по IP і чому його одного замало
Тут все просто: рахуємо запити з IP за певне вікно часу і після N запитів відмовляємо. У мене вийшло три реєстрації на годину з адреси і п’ять запитів на скидання пароля.
Але тут є дві деталі.
Рахунок мусить бути атомарним. Можна було б зробити «прочитати лічильник → перевірити → записати», але це погана ідея: між читанням і записом пролазить паралельний запит. У мене це один запит, який одночасно вставляє або оновлює рядок і повертає нове значення:
```sql INSERT INTO «IpRateLimit» («ip», «scope», «count», «windowEnd») VALUES ($1, $2, 1, $3) ON CONFLICT («ip», «scope») DO UPDATE SET «count» = CASE WHEN "IpRateLimit«.«windowEnd» < NOW() THEN 1 ELSE "IpRateLimit«.«count» + 1 END, «windowEnd» = CASE WHEN "IpRateLimit«.«windowEnd» < NOW() THEN $3 ELSE "IpRateLimit«.«windowEnd» END RETURNING «count» ```
Скидання вікна живе всередині того самого `CASE`, тож окремої задачі, яка ходить і чистить прострочені лічильники, не потрібно. Порівняння з лімітом робиться вже по поверненому числу.
Стан лежить у Postgres, а не в Redis — навмисно. У моїй архітектурі веб не має доступу до Redis, той приватний для воркера. Це повільніше за Redis на пару мілісекунд, і мене це влаштовує: ці ендпоінти й так упираються в поштовий провайдер, а не в базу.
З IP теж є нюанс. Клієнтський IP у `x-forwarded-for` — це ланцюг проксі, потрібен перший елемент. І треба вирішити, що робити, якщо заголовка взагалі немає. Спокуса — пропустити перевірку. У мене навпаки: запит без заголовка падає у спільний бакет і все одно рахується. Інакше виходить ліміт, який вимикається, якщо не надсилати один заголовок.
Ліміт на отримувача — те, що більшість пропускає
Ліміт по IP захищає мій сервіс, але не дуже допомагає людині, на яку йде спам: той, хто ротує адреси, спокійно завалить одну скриньку з сотні різних IP.
Тому на «забули пароль» у мене стоїть другий ліміт — на адресу отримувача. Три листи на годину на одну скриньку, звідки б вони не приходили.
Тут і виліз ще один момент, про який я не думав. Наприклад, дві адреси:
Це одна скринька. Gmail ігнорує крапки в локальній частині і все після плюса. Якщо брати для ліміту той рядок, який прийшов із форми, можна отримати безліч різних ключів для однієї скриньки. Тоді такий ліміт легко обходиться.
Тож ключ треба нормалізувати:
```ts
const DOT_INSENSITIVE_DOMAINS = new Set([’gmail.com’, ’googlemail.com’])
export function rateLimitKeyForEmail(email: string): string {
const normalized = email.trim().toLowerCase()
const [localPart = ’’, domain = ’’] = normalized.split(’@’)
if (!domain) return normalized
const withoutTag = localPart.split(’+’)[0]
const local = DOT_INSENSITIVE_DOMAINS.has(domain) ? withoutTag.replaceAll(’.’, ’’) : withoutTag
return `${local}@${domain}`
}
```
Тут важливіше не стільки саме рішення, скільки один момент: це ключ ліміту і тільки ключ ліміту. Він ніколи не вирішує, куди піде лист. Плюс-теги — легальна і корисна штука, люди ними користуються свідомо, і «виправляти» чиюсь адресу перед відправкою не можна. Нормалізоване значення використовується тільки всередині rate limit і більше ніде.
Крапки я прибираю тільки для Gmail, бо це його правило, а не загальний стандарт. Для інших доменів крапка може бути значущою.
Пастка: закриваючи одну дірку, можна відкрити іншу
Тут є ще одна важлива пастка.
«Забули пароль» не повинен по-різному відповідати на зареєстровану й незареєстровану адресу. Інакше форма перетворюється на зручний спосіб перевірити, чи є в тебе акаунт конкретної людини. Це в мене вже було зроблено з самого початку: відповідь завжди однакова, незалежно від того, знайшовся користувач чи ні.
А тепер додаємо rate limit. Якщо відповідь після спрацювання ліміту відрізняється від звичайної — інший код, інше тіло або навіть помітно інший час — можна випадково повернути ту саму проблему іншим шляхом. Атакуючий надсилає чотири запити на адресу: якщо сервер відповів «занадто багато», значить попередні три щось робили, значить адреса зареєстрована.
У результаті endpoint, який раніше нічого не видавав, починає розповідати про існування акаунта іншим способом.
Тому навіть після спрацювання ліміту він відповідає так само, як і завжди. Ліміт видно в моїх логах і ніде більше. На перший погляд, це дивно: сервер нічого не зробив, але все одно повернув `ok`. Тут це правильна поведінка.
Чого я не зробив
Капчі немає. Я її не поставив і поки не планую.
Причини дві. По-перше, вона додає зайвий крок для всіх нормальних користувачів, а на продукті з десятком користувачів кожен, хто дійшов до форми, для мене важливіший за ту економію. По-друге, і для цієї проблеми це важливіше: капча захищає мій сервіс, але не саму жертву. Вона зменшить кількість автоматичних запитів, але людина, яка вручну надішле десять ресетів на чужу адресу, пройде її без проблем. Ліміт на отримувача працює і проти бота, і проти ручних запитів.
Якщо ботів стане більше — додам. Але це буде додатковий захист, а не заміна лімітів.
І ще момент: фільтр одноразових поштовиків, який у мене вже стояв, тут взагалі не допоміг. Адреси були звичайні. Він вирішує іншу задачу — щоб алерти не йшли у скриньку, яка зникне через годину. Тому сприймати його як захист від такого зловживання не варто.
Дрібниця, на якій я запнувся в тестах
Інтеграційні тести не надсилають `x-forwarded-for`. Тобто всі вони, за моєю ж логікою, падають в один спільний бакет `unknown`. Перший тест на ліміт проходить, другий бачить залишений першим лічильник і падає.
Виправив це очищенням таблиці лімітів перед кожним тестом. Сам пошук причини зайняв більше часу, ніж написання рядка.
Короткий чеклист
Якщо у вас є публічна форма, яка може відправити листа на введену користувачем адресу:
1. Випишіть усі такі ендпоінти. Список зазвичай довший, ніж здається.
2. Ліміт по IP — щоб захистити себе.
3. Ліміт на отримувача — щоб захистити того, чию адресу вписують. Це різні задачі.
4. Нормалізуйте ключ ліміту (крапки й плюс-теги), але ніколи не адресу доставки.
5. Перевірте, що лімітована відповідь не відрізняється від звичайної там, де відповідь і так має бути однаковою.
6. Порахуйте, що ще живе на тому ж домені-відправнику. Якщо там щось важливе — це і є справжня ціна питання.
Наостанок
Найбільше мене тут здивувало те, що проблема взагалі не екзотична. Будь-яка публічна форма, яка може відправити листа на введену адресу, потенційно працює так, поки ти не поставиш обмеження. Я просто раніше не дивився на власну сторінку реєстрації з такого боку.
Якщо хтось уже стикався з таким, цікаво почути, як вирішували. Особливо цікавий ліміт на отримувача — я сам про нього згадав уже після того, як знайшов проблему.
6 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівЧудова стаття! Справді системний і виважений підхід.
Декілька малих питань і зауважень:
1. А що, можна було вписувати довільний текст у форму реєстрації? Якщо ні, то чим це спам? І який був сенс у атакуючого робити запит скидання паролю на адресу, що не зареєстрована?
2. Щонайменше, googlemail.com == gmail.com. Здається, таких тільки два.
3. Чому не ліміт на незареєстровану адресу, але такий, що показується?
Дякую.
1. Довільний текст вписати не можна — тільки валідну адресу, і в цьому вся суть. Схема була двокрокова, і я, здається, описав її в статті недостатньо ясно: спершу реєстрація на чужу адресу, і аж потім запит скидання пароля вже для неї. Тобто на момент ресету адреса зареєстрована — її щойно зареєстрував сам атакуючий(моє бачення ситуації).
Сенс саме в двох кроках: реєстрація дає один лист, а ресет можна повторювати скільки завгодно. Перший крок — підготовка, другий — те, чим б’ють.
2. Так, і обидва в коді є: new Set([’gmail.com’, ’googlemail.com’]). Крапки прибираю тільки для них, бо це їхнє правило, а не загальний стандарт.
А плюс-теги — інша історія: їх підтримують і Outlook, і iCloud, і Fastmail, і Proton. Тому +tag відрізається для всіх доменів, а крапки — тільки для цих двох. Два різні правила з різним охопленням.
3. Тут пастка саме в слові «показується». Якщо лімітована відповідь видима тільки для незареєстрованих адрес, то її поява означає «акаунта немає», а її відсутність — «акаунт є». Оракул той самий, просто зайшли з іншого боку.
Відрізнятись може лише та поведінка, яка й так однакова в обох випадках. Щойно вона розходиться — байдуже, в який бік.
Чому не перевіряти перед віправкою форгет пасс на те, чи існує цей мейл у базі користувачів?
Перевіряємо, звісно. Просто не повідомляємо результат назовні: якщо на «адреси немає» відповісти інакше, ніж на «лист надіслано», форма стає способом перевіряти, у кого тут є акаунт. Тому відповідь однакова завжди — немає, є, спрацював ліміт. Різницю видно лише в моїх логах.
І окремо: лист не піде на непідтверджену адресу. Зареєструвати чужу пошту може будь-хто, але поки на ній не натиснули лінк — вона не його. Саме це й закриває історію зі статті.
tl;dr
ai://invisible-captcha
Дякую