Главная / Статьи / Блокировка электронных писем с просьбой о регистрации, которые невозможно доставить, с помощью хука Supabase Auth и DNS

Блокировка электронных писем с просьбой о регистрации, которые невозможно доставить, с помощью хука Supabase Auth и 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. В разделе RFC, посвящённом определению целевого хоста говорится, что когда список MX пуст, сам домен считается почтовым сервером. Следовательно, именно записи типа A или AAAA домена определяют исход.
  2. Если все записи MX недоступны, это является ошибкой. В том же разделе указано, что если записи MX существуют, но ни одна из них не работает, доставка должна провалиться. На практике: если ни один из указанных серверов обмена почтой не приводит к IP-адресу, заявку следует отклонить.
  • Нулевой MX означает, что домен не принимает почту. RFC 7505 (раздел 3) определяет единственную запись MX с приоритетом 0 и пустым значением обменного сервера, которое в файлах зоны записывается как "." (формат записи описан в разделе 3.3.9 RFC 1035), что является прямым указанием на то, что домен не принимает электронную почту.
  • Реализация хука

    Приведённая ниже функция применяет эти правила. Она считывает данные пакета, извлекает домен после символа @ и возвращает код 400 при некорректном вводе. Затем она ищет записи MX и обрабатывает каждый случай: при отсутствии записей проверяет собственные адреса домена; при наличии одной записи MX с значением null отклоняет запрос; в остальных случаях проходит по всем записям и принимает запрос, как только у кого-то из них найдётся 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-серверы напрямую замедляют процесс регистрации, поэтому необходимо задать таймауты для каждого запроса к 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 выявляют опечатки и поддельные домены, но не реальные, однако неиспользуемые почтовые ящики; подтверждение по электронной почте остается единственным доказательством владения.
  • Перед использованием этого метода в производстве убедитесь в подлинности подписи хука и времени выполнения поиска.