Блокировка электронных писем с просьбой о регистрации, которые невозможно доставить, с помощью хука Supabase Auth и DNS
Используйте хук Supabase перед созданием пользователя и запросы MX, A и AAAA для отклонения регистраций пользователей, домен электронной почты которых не может принимать сообщения в соответствии с правилами RFC 5321.
Форма регистрации, которая проверяет электронные адреса с помощью регулярных выражений, примет любой адрес в подобии формата, включая адреса на доменах, которые вообще не могут принимать почту. Поскольку 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-адреса хоста. Три правила определяют, может ли домен принимать почту:
- В разделе RFC, посвящённом определению целевого хоста говорится, что когда список MX пуст, сам домен считается почтовым сервером. Следовательно, именно записи типа A или AAAA домена определяют исход.
- Если все записи MX недоступны, это является ошибкой. В том же разделе указано, что если записи MX существуют, но ни одна из них не работает, доставка должна провалиться. На практике: если ни один из указанных серверов обмена почтой не приводит к IP-адресу, заявку следует отклонить.
Реализация хука
Приведённая ниже функция применяет эти правила. Она считывает данные пакета, извлекает домен после символа @ и возвращает код 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, чтобы посторонние не могли вызывать функцию.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, а не ожидать пустой массив.