Blokowanie nieprzesyłanych e-maili rejestracyjnych za pomocą hooka Supabase Auth i DNS
Użyj hooka Supabase przed utworzeniem konta oraz zapytań MX, A i AAAA, aby odrzucić rejestracje użytkowników, których domena e-mail nie może odbierać wiadomości, zgodnie z zasadami określonymi w RFC 5321.
Formularz rejestracyjny, który sprawdza adresy e-mail za pomocą wyrażeń regularnych, przyjmie każdy tekst przypominający adres e-mail, włączając adresy z domen, które w ogóle nie mogą odbierać wiadomości. Ponieważ Supabase tworzy rekord użytkownika przed kliknięciem w e-mail potwierdzający, każdy taki adres pozostawia nieweryfikowaną wiersz w bazie danych. Ten przewodnik pokazuje, jak przechwycić proces rejestracji za pomocą hooka Supabase Auth oraz jak wykorzystać zapytania DNS do odrzucenia domen bez działającego serwera pocztowego, co mówią odpowiednie zasady SMTP oraz gdzie konieczne jest wzmocnienie tego rozwiązania przed wprowadzeniem go do produkcji.
Gdzie podłączyć się do procesu rejestracji
Z Supabase prośby o rejestrację zazwyczaj przychodzą bezpośrednio z biblioteki klienta, więc nie ma własnej ścieżki serwera, w której można by najpierw zweryfikować adres. Supabase rozwiązuje ten problem za pomocą Auth Hooks: punktów końcowych lub funkcji bazy danych, które Supabase Auth wywołuje w określonych momentach swojego procesu. Hook before-user-created uruchamia się przed dodaniem nowego użytkownika i może zatwierdzić lub odrzucić prośbę.
Poniższe fragmenty pochodzą z projektu Next.js, ale sama weryfikacja odbywa się w funkcji Supabase Edge Function, więc nie zależy od twojego frameworku front-endowego.
Włączanie hooka w projekcie lokalnym
Gdy Supabase jest uruchamiany lokalnie za pomocą CLI, interfejs Studio może nie oferować ustawienia dla hooki autoryzacyjnych. W takim przypadku włącz hooka w pliku supabase/config.toml, usuwając komentarze i uzupełniając odpowiednią sekcję:
#[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 to punkt końcowy, do którego będzie dzwonił Supabase, natomiast secrets zawiera sekret służący do podpisywania żądań hooka, który jest używany przez Supabase do ich autoryzacji. Zwróć uwagę na adres hosta: używa się host.docker.internal, ponieważ lokalna infrastruktura Supabase działa w środowisku Docker, a ten adres umożliwia jej kontenerom połączenie z serwerem funkcji.
Tworzenie funkcji Edge
Punkt końcowy w tym przypadku to funkcja Edge – funkcja po stronie serwera, którą Supabase rozprzestrzenia na globalnie rozmieszczonym środowisku wykonawczym znajdującym się w pobliżu użytkowników. Stwórz ją za pomocą CLI:
npx supabase functions new before-user-created
Polecenie tworzy nową folder pod katalogiem supabase/functions, a logika znajduje się w pliku index.ts. Zgodnie z dokumentacją Supabase dotyczącą hooka before-user-created, Supabase wysyła dane zawierające metadane żądania oraz informacje o użytkowniku, który ma zostać utworzony:
{
"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
}
}
Pole istotne dla tej weryfikacji to user.email.
Jak daleko powinna sięgać weryfikacja?
Gdy masz adres e-mail, jego domenę można sprawdzić za pomocą DNS. Można pójść dalej: ustalić adres IP serwera pocztowego, nawiązać do niego połączenie TCP i przeanalizować rozmowę SMTP. Zazwyczaj nie jest to opłacalne – wiele serwerów odrzuca lub kłamie w odpowiedzi na takie próby, te weryfikacje powodują znaczną opóźnienie przy każdej rejestracji, a jedynym wiarygodnym dowodem na istnienie skrzynki pocztowej jest kliknięcie przez użytkownika w link potwierdzający.
Pрактиcznym kompromisem jest sprawdzenie, czy domena posiada przynajmniej jeden serwer pocztowy, który rzeczywiście odpowiada adresowi IP. Dzięki temu tanio eliminuje się błędy pisowni oraz wymyślone domeny. Osoba zdeterminowana do zanieczyszczenia twojej bazy danych nadal może zarejestrować prawdziwe domeny, więc traktuj to jako filtr przeciwko szumowi, a nie jako ochronę przed nadużyciami. To, jak daleko posunąć się w tym kierunku, zależy od twojego modelu zagrożeń.
Co mówią zasady SMTP na temat rekordów MX
Domeny publikują swoje serwery pocztowe jako rekordy MX (mail exchange), zgodnie z definicją w RFC 5321 dla SMTP. Moduł dns w Node udostępnia funkcje resolveMx(), która zwraca wartości exchange oraz priority każdego rekordu, oraz funkcje resolve4() i resolve6(), które zwracają adresy IPv4 i IPv6 danego hosta. Trzy zasady decydują o tym, czy dana domena może otrzymywać e-maile:
- Rozdział RFC dotyczący lokalizacji hosta docelowego stanowi, że gdy lista MX jest pusta, sam domenę traktuje się jako serwer pocztowy. Dlatego to właśnie rekordy A lub AAAA domeny decydują o wyniku.
- Ten sam rozdział wymaga, aby w przypadku istnienia rekordów MX, ale braku działających z nich, dostawa wiadomości musiała zawieść. W praktyce: jeśli żaden z wymienionych serwerów wymiany nie odpowiada adresowi IP, należy odrzucić rejestrację.
Implementacja hooka
Funkcja poniżej stosuje te zasady. Odczytuje treść przesyłaną w pakiecie, wyodrębnia domenę po znaku @ i zwraca kod 400 w przypadku nieprawidłowego danych wejściowych. Następnie sprawdza rekordy MX i radzi sobie z każdym przypadkiem: jeśli nie ma żadnych rekordów, sprawdza własne adresy domeny; jeśli istnieje tylko jeden rekord MX o wartości null, odrzuca próbę; w pozostałych przypadkach przechodzi przez kolejne rekordy i akceptuje pierwszy, który ma adres IP. Pusty obiekt JSON sygnalizuje Supabase, że można kontynuować. Funkcja pomocnicza hasIpAddress sprawdza adresy IPv4 i IPv6 równolegle za pomocą Promise.allSettled, dzięki czemu niepowodzenie w jednej próbie nie maskuje sukcesu w drugiej.
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);
};
Potencjalne problemy w tym kodzie wymagające poprawy
Logika jest zgodna z wytycznymi RFC, ale kilka szczegółów zachowuje się inaczej niż zakłada kod:
resolveMx()rzuca błąd zamiast zwracać pustą listę. W przypadku domeny bez rekordów MX lub nieistniejącej domeny Node odrzuca próbę z błędami takimi jakENODATAlubENOTFOUND. Oznacza to, że gałąź obsługująca domyślne MX rzadko jest wykorzystywana; zamiast tego blokcatchzwraca błąd 500, a legalne domeny polegające na domyślnym MX są odrzucane jako błędy serwera. Przechwytaj błądENODATAwokół funkcjiresolveMx()i ucieknij się do metodyhasIpAddress(domain).- Sprawdzenie wartości null dla MX może nie być dokładne. Node zgłasza wartość null dla MX poprzez pustą ścieżkę
exchangezamiast „.”. Warunekif (record.exchange)w pętli nadal odrzuca takie domeny, ale należy porównać obie wartości, aby jasno określić intencję.
auth: „none” przyjmuje każdego wywołującego. Należy weryfikować podpisany żądanie za pomocą sekretu skonfigurowanego w config.toml, zgodnie z opisem w dokumentacji haków Auth, aby osoby z zewnątrz nie mogły wywołać funkcji.node:dns/promises działa poprzez warstwę kompatybilności Node. Przetestuj to w swoim środowisku implementacji.Zezwalanie na nieautoryzowane wywołania funkcji
Na koniec zarejestruj funkcję w pliku config.toml i wyłącz weryfikację JWT dla niej. Nazwa w nawiasach musi odpowiadać nazwie folderu zawierającego funkcję w katalogu supabase/functions:
[functions.before-user-created]
verify_jwt = false
Weryfikacja JWT jest wyłączona, ponieważ podczas rejestracji nikt jeszcze nie jest zalogowany; to własna kontrola podpisu hooka, a nie token użytkownika, powinna chronić ten endpoint.
Główne wnioski
- Hook Auth typu
before-user-createdumożliwia walidację adresów po stronie serwera, nawet gdy rejestracja jest inicjowana z biblioteki klienckiej. - Domena może otrzymywać e-maile, jeśli jeden z jej hostów MX zostanie rozwiązany, lub – w przypadku braku rekordów MX – jeśli sama domena zostanie rozwiązana; wartość null dla MX oznacza, że domena nie akceptuje żadnych hostów.
- W Node funkcja
resolveMx()rzuca błąd w przypadku braku rekordów, więc należy wyraźnie obsłużyć błądENODATA, zamiast oczekiwać pustego arraya.