Головна / Статті / Блокування електронних листів із запитами на реєстрацію, які неможливо доставити, за допомогою Supabase Auth Hook та DNS

Блокування електронних листів із запитами на реєстрацію, які неможливо доставити, за допомогою Supabase Auth Hook та DNS

Використовуйте хук Supabase перед створенням користувача та пошук MX, A та AAAA для відхилення реєстрацій користувачів, чиї домени електронної пошти не можуть отримувати листи, відповідно до правил RFC 5321.

1680 слів

Форма реєстрації, яка перевіряє електронні адреси лише за допомогою регулярних виразів, прийматиме будь-які дані у форматі адреси, включаючи адреси з доменів, які взагалі не можуть отримувати листи. Оскільки Supabase створює запис користувача ще до того, як буде натиснуто на електронний лист з підтвердженням, кожна така адреса залишає неперевірений запис у вашій базі даних. У цьому посібнику показано, як перехоплювати процес реєстрації за допомогою гака Supabase Auth та використовувати пошук у DNS для відхилення доменів, які не мають функціонального поштового сервера, що передбачено відповідними правилами SMTP, та де цей підхід потребує додаткових заходів безпеки перед впровадженням у продакшн.

Де під’єднатися до процесу реєстрації

З Supabase запити на реєстрацію зазвичай надходять безпосередньо з клієнтської бібліотеки, тому у вас немає власного серверного маршруту, де можна було б спочатку перевірити адресу. Supabase вирішує цю проблему за допомогою Auth Hooks: це кінцеві точки або функції бази даних, які Supabase Auth викликає у певні моменти своєї роботи. Гак before-user-created виконується перед додаванням нового користувача та може дозволити або відхилити запит.

Наведені нижче фрагменти коду походять з проекту Next.js, але сама перевірка відбувається у функції Supabase Edge Function, тому вона не залежить від вашої фронтенд-фреймворк.

Увімкнення гака у локальному проекті

Коли Supabase працює локально через CLI, інтерфейс Studio може не мати налаштування для хуків автентифікації. У такому разі увімкніть хук у файлі supabase/config.toml, знімаючи коментар та заповнюючи відповідний розділ:

#[auth.hook.before_user_created]
#enabled = true
#secrets="v1,whsec_ some secret" (needs to be added)
#uri = "http://host.docker.internal:54321/functions/v1/before-user-created"

uri — це кінцева точка, до якої буде здійснюватися виклик від Supabase, а secrets містить секрет підпису, який використовується Supabase для підписування кожного запиту хука. Зверніть увагу на хост: використовується host.docker.internal, оскільки локальна стек Supabase працює в Docker, і ця назва дозволяє її контейнерам отримувати доступ до сервера функцій.

Створення Edge Function

Кінцева точка тут — це Edge Function, функція з боку сервера, яку Supabase розгортає у глобально розподіленому середовищі виконання поблизу користувачів. Створіть її за допомогою CLI:

npx supabase functions new before-user-created

Команда створює нову папку під supabase/functions, а логіка розміщується у її файлі index.ts. Згідно з документацією Supabase щодо гака before-user-created, Supabase надсилає пакет даних із метаданими запиту та інформацією про користувача, якого збираються створити:

{
 "metadata": {
 "uuid": "8b34dcdd-9df1–4c10–850a-b3277c653040",
 "time": "2025–04–29T13:13:24.755552–07:00",
 "name": "before-user-created",
 "ip_address": "127.0.0.1"
 },
 "user": {
 "id": "ff7fc9ae-3b1b-4642–9241–64adb9848a03",
 "aud": "authenticated",
 "role": "",
 "email": "valid.email@supabase.com",
 "phone": "",
 "app_metadata": {
 "provider": "email",
 "providers": ["email"]
 },
 "user_metadata": {},
 "identities": [],
 "created_at": "0001–01–01T00:00:00Z",
 "updated_at": "0001–01–01T00:00:00Z",
 "is_anonymous": false
 }
}

Поле, яке має значення для цієї перевірки, — це user.email.

Наскільки далеко має сягати перевірка?

Як тільки у вас є адреса, її домен можна перевірити за допомогою DNS. Можна піти ще далі: з’ясувати IP-адресу поштового сервера, відкрити до нього TCP-з’єднання та перевірити роботу протоколу SMTP. Однак це зазвичай не варте зусиль. Багато серверів відхиляють такі запити або надають хибну інформацію, перевірки додають значну затримку під час кожної реєстрації, а єдиним надійним доказом існування поштової скриньки є клік користувача на посилання підтвердження.

Прагматичним компромісом є перевірка того, чи має домен принаймні один поштовий сервер, який дійсно перетворюється на IP-адресу. Це дешево видаляє опечатки та вигадані домени. Люди, які хочуть забруднити вашу базу даних, все одно можуть зареєструвати справжні домени, тому сприймайте це як фільтр від шуму, а не як захист від зловживань. Те, наскільки далеко йти за межі цього, залежить від вашої моделі загроз.

Що кажуть правила SMTP про записи MX

Домени публікують свої поштові сервери як записи MX (mail exchange), як це визначено для SMTP у RFC 5321. Модуль dns у Node надає функції resolveMx(), яка повертає хост exchange та priority кожного запису, а також resolve4() та resolve6(), які повертають IPv4 та IPv6-адреси хоста. Три правила визначають, чи може домен отримувати пошту:

  1. Відсутність записів MX означає імпліцитний MX. У розділі RFC про визначення цільового хоста зазначено, що коли список MX порожній, сам домен вважається поштовим сервером. Тож саме записи типу A або AAAA домену визначають результат.
  2. Якщо всі записи MX є непридатними, це є помилкою. У тому ж розділі вимагається, що якщо записи MX існують, але жоден з них не працює, доставка має зазнати невдачі. На практиці: якщо жоден з перелічених серверів обміну не відповідає IP-адресі, потрібно відхилити заявку.
  • Null-значення MX означає, що домен не приймає електронну пошту. RFC 7505 (розділ 3) визначає єдиний запис MX із пріоритетом 0 та порожнім значенням обміну, яке у файлах зони записується як „.“ (формат запису описаний у розділі 3.3.9 RFC 1035), що є прямим позначенням того, що домен не приймає електронну пошту.
  • Реалізація механізму hook

    Функція нижче застосовує ці правила. Вона читає дані, які надходять, витягує домен після символа @, та повертає код 400 у разі некоректних даних. Потім вона шукає записи MX та обробляє кожен випадок: якщо записів немає, перевіряються власні адреси домену; якщо є лише один недійсний запис MX, запит відхиляється; у інших випадках переглядаються всі можливі сервери обміну та запит приймається, як тільки хоча б один з них має IP-адресу. Порожній об’єкт JSON сигналізує Supabase про можливість продовження обробки. Допоміжна функція hasIpAddress одночасно перевіряє IPv4 та IPv6 за допомогою Promise.allSettled, щоб невдача в одному пошуку не приховувала успіх у іншому.

    import "@supabase/functions-js/edge-runtime.d.ts";
    import { withSupabase } from "@supabase/server";
    import dns from "node:dns/promises";
    
    export default {
      fetch: withSupabase({ auth: "none" }, async (req) => {
        // Called by another service with a secret key
        // ctx.supabaseAdmin bypasses RLS — use for privileged operations
    
        try {
          const r = await req.json();
          const email = r.user.email;
    
          //seems like strict email validation is not required,initial thoughts,
          //  needs to be investigated
    
          if (typeof email !== "string") {
            return Response.json({
              error: {
                message: "",
              },
            }, {
              status: 400,
            });
          }
    
          const domain = (email as string).split("@")[1];
    
          if (!domain) {
            return Response.json({
              error: {
                message: "bad request",
              },
            }, {
              status: 400,
            });
          }
    
          const mx_records = await dns.resolveMx(domain);
    
          if (mx_records.length === 0) { //case 1: no mx record present
            // email server may be domain itself
            const mail_server = domain;
            const has_ip_address = await hasIpAddress(mail_server);
            if (has_ip_address) {
              return Response.json({});
            } else {
              return Response.json({
                error: {
                  message: "bad request",
                },
              }, {
                status: 400,
              });
            }
          } else {
            if (mx_records.length === 1) { // case 3: domain does not accept mails
              const record = mx_records[0];
              if (record.priority === 0 && record.exchange === ".") {
                return Response.json({
                  error: {
                    message: "bad request",
                  },
                }, {
                  status: 400,
                });
              }
            }
    
            for (const record of mx_records) { // inspection for case 2
              if (record.exchange) {
                const has_ip_address = await hasIpAddress(record.exchange);
                if (has_ip_address) {
                  return Response.json({});
                }
              }
            }
    
            return Response.json({
              error: {
                message: "bad request",
              },
            }, {
              status: 400,
            });
          }
        } catch (_e) {
          return Response.json({
            error: {
              message: "internal server error",
            },
          }, {
            status: 500,
          });
        }
      }),
    };
    
    const hasIpAddress = async (mail_server: string): Promise<boolean> => {
      const [ipv4, ipv6] = await Promise.allSettled([
        dns.resolve4(mail_server),
        dns.resolve6(mail_server),
      ]);
    
      return (ipv4.status === "fulfilled" && ipv4.value.length > 0) ||
        (ipv6.status === "fulfilled" && ipv6.value.length > 0);
    };
    

    Проблеми в цьому коді, які варто виправити

    Логіка відповідає стандартам RFC, але деякі деталі поводяться інакше, ніж передбачено кодом:

    • resolveMx() викидає помилку замість того, щоб повертати порожній список. Для домену без записів MX або для неіснуючого домену Node відхиляє запит із помилками на кшталт ENODATA або ENOTFOUND. Це означає, що варіант роботи з імпліцитним MX рідко використовується; натомість блок catch повертає код 500, і законні домени, які покладаються на імпліцитний MX, відхиляються як серверні помилки. Перехопіть помилку ENODATA біля функції resolveMx() та перейдіть на використання hasIpAddress(domain).
    • Перевірка значення null для MX може давати хибні результати. Node повідомляє про null MX із порожньою рядком exchange замість символа „.“. Умова if (record.exchange) у циклі все одно відхиляє такі домени, але для більшої ясності порівнюйте обидва значення.
  • Підпис хука ніколи не перевіряється. У вбудованому коментарі згадується секрет, але auth: "none" дозволяє викликати функцію будь-кому. Перевіряйте підписаний запит за допомогою секрету, налаштованого в config.toml, як описано в документації до хуків Auth, щоб сторонні особи не могли викликати функцію.
  • Перевірте формат відмови. Supabase очікує помилки у певній формі (включаючи код статусу HTTP у об’єкті помилки); переконайтеся у поточному форматі в документації до хука, щоб користувачі бачили зрозуміле повідомлення.
  • Додайте таймаути. Повільні сервери DNS безпосередньо уповільнюють процес реєстрації, тому обмежте час кожного пошуку.
  • Час виконання: Edge Functions працюють на Deno, а node:dns/promises функціонує через його шар сумісності з Node. Протестуйте це у вашій меті розгортання.
  • Дозволення непідтверджених викликів функції

    Наостанок зареєструйте функцію в config.toml та вимкніть перевірку JWT для неї. Назва в дужках має збігатися з назвою папки функції в supabase/functions:

    [functions.before-user-created]
    verify_jwt = false
    

    Перевірка JWT вимкнена, тому що під час реєстрації ніхто ще не увійшов у систему; сама перевірка підпису гака, а не токен користувача, має захищати цей кінцевий пункт.

    Основні висновки

    • Гак Auth before-user-created дозволяє перевіряти адреси на серверному рівні навіть тоді, коли реєстрація ініціюється з боку клієнтської бібліотеки.
    • Домен може отримувати електронні листи, якщо хоча б один з його MX-хостів резолюється, або, якщо MX-записів немає, якщо сам домен резолюється; значення null для MX означає, що він не приймає жодних хостів.
    • У Node функція resolveMx() кидає помилку при відсутності записів, тому необхідно явно обробляти помилку ENODATA, замість того щоб очікувати порожнього масиву.
  • Перевірки DNS виявляють опечатки та фальшиві домени, але не справжні, проте невикористовувані поштові скриньки; підтвердження електронною поштою залишається єдиним доказом власності.
  • Перевірте підпис хука та часи пошуку перед тим, як використовувати його у продакшені.