Strona główna / Artykuły / Blokowanie nieprzesyłanych e-maili rejestracyjnych za pomocą hooka Supabase Auth i DNS

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.

1680 słów

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:

  1. 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.
  2. 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ę.
  • Wartość MX równa null oznacza, że domen nie przyjmuje żadnych wiadomości e-mail. RFC 7505 (sekcja 3) definiuje pojedynczy rekord MX o priorytecie 0 i pustym adresie wymiany, zapisywany jako „.” w plikach strefy (format rekordu jest opisany w sekcji 3.3.9 RFC 1035), jako wyraźne stwierdzenie, że domen nie przyjmuje e-maili.
  • 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 jak ENODATA lub ENOTFOUND. Oznacza to, że gałąź obsługująca domyślne MX rzadko jest wykorzystywana; zamiast tego blok catch zwraca błąd 500, a legalne domeny polegające na domyślnym MX są odrzucane jako błędy serwera. Przechwytaj błąd ENODATA wokół funkcji resolveMx() i ucieknij się do metody hasIpAddress(domain).
    • Sprawdzenie wartości null dla MX może nie być dokładne. Node zgłasza wartość null dla MX poprzez pustą ścieżkę exchange zamiast „.”. Warunek if (record.exchange) w pętli nadal odrzuca takie domeny, ale należy porównać obie wartości, aby jasno określić intencję.
  • Podpis haka nigdy nie jest weryfikowany. Komentarz wewnątrz kodu wspomina o sekrecie, ale 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.
  • Sprawdź format odrzucenia. Supabase oczekuje błędów w określonej formie (włącznie z kodem stanu HTTP w obiekcie błędu); upewnij się co do aktualnego formatu w dokumentacji haka, aby użytkownicy widzieli sensowną wiadomość.
  • Dodaj czasy wygaśnięcia. Powolne serwery DNS bezpośrednio opóźniają proces rejestracji, dlatego należy zaimplementować czas wygaśnięcia dla każdego zapytania.
  • Czas wykonywania: Funkcje Edge działają w środowisku Deno, a 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-created umoż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łąd ENODATA, zamiast oczekiwać pustego arraya.
  • Sprawdzania DNS wykrywają błędy pisowni i fałszywe domeny, a nie rzeczywiste, lecz niewykorzystywane skrzynki pocztowe; potwierdzenie e-mailem pozostaje jedynym dowodem własności.
  • Zanim zaczniesz używać tego rozwiązania w produkcji, sprawdź podpis hooka oraz czasy wyszukiwania powiązań.