使用 Supabase Auth Hook 与 DNS 阻止无法送达的注册邮件
根据 RFC 5321 的规定,使用 Supabase 的预用户创建钩子以及 MX、A 和 AAAA 查询功能,拒绝那些邮箱域名无法接收邮件的注册请求。
仅使用正则表达式验证电子邮件的注册表单会接受任何类似地址的格式,包括那些根本无法接收邮件的域名上的地址。由于 Supabase 会在点击确认邮件之前创建用户记录,因此所有此类地址都会在数据库中留下未验证的记录。本指南介绍了如何通过 Supabase Auth 钩子拦截注册行为,利用 DNS 查询来拒绝那些没有正常邮件服务器的域名,相关 SMTP 规则的内容,以及在生产环境部署前需要加强安全性的地方。
在何处插入注册流程的钩子
使用 Supabase 时,注册请求通常直接来自客户端库,因此你没有自己的服务器路由来先验证地址。Supabase 通过 认证钩子 解决了这个问题:这些是 Supabase Auth 在其处理流程中的特定节点会调用的端点或数据库函数。before-user-created 钩子在新增用户之前执行,可以允许或拒绝该请求。
下面的代码片段来自一个 Next.js 项目,但验证操作实际上是在 Supabase Edge Function 中运行的,因此它不依赖于你的前端框架。
在本地项目中启用该钩子
当通过 CLI 在本地运行 Supabase 时,Studio 界面可能不提供 Auth hooks 的设置选项。在这种情况下,需在 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 用于为每个钩子请求签名所需的密钥。注意主机地址:由于本地 Supabase 环境运行在 Docker 中,因此使用 host.docker.internal,该地址能让相关容器连接到函数服务器。
创建边缘函数
这里的端点指的是边缘函数,即 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记录的规定
域名会按照RFC 5321中对SMTP的定义,通过MX(邮件交换)记录来公布其邮件服务器信息。Node的dns模块提供了resolveMx()函数,可返回每条记录对应的exchange主机及priority值;还有resolve4()和resolve6()函数,可分别返回主机的IPv4和IPv6地址。判断一个域名是否能够接收邮件需遵循三条规则:
- 没有MX记录意味着存在隐含的MX记录。RFC中关于定位目标主机的章节指出,当MX列表为空时,该域名本身即被视为邮件服务器。因此由该域名的A记录或AAAA记录来决定最终结果。
- 所有MX记录都无法使用即属于错误情况。同一章节要求,如果存在MX记录但全部无效,则邮件投递必定会失败。实际操作中:若列表中的任何交换服务器都无法解析为IP地址,则应拒绝注册请求。
钩子实现方式
下面的函数会应用这些规则。它读取请求数据,提取出 @ 后的域名,若输入格式不正确则返回 400 错误码。随后它会查询 MX 记录并处理各种情况:如果没有 MX 记录,则检查该域名自身的地址;如果只有一个无效的 MX 记录,则拒绝请求;否则会依次检查各个交换服务器,一旦发现有服务器提供 IP 地址则接受该请求。空的 JSON 对象则表示让 Supabase 继续处理。辅助函数 hasIpAddress 会使用 Promise.allSettled 同时查询 IPv4 和 IPv6 地址,这样其中一个查询失败不会影响另一个查询的结果。
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的合法域名也会因服务器错误而被拒绝。应在resolveMx()周围捕获ENODATA异常,然后转而使用hasIpAddress(domain)函数。- 空值MX检查可能结果不一致。Node会将值为空的
exchange字符串视为空值MX,而非“.”。虽然循环中的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验证;保护此端点的应是钩子自身的签名检查,而非用户令牌。
关键要点
- 即使注册是通过客户端库触发的,
before-user-createdAuth钩子也能让你在服务器端验证地址。 - 只要域名的某个MX主机能够解析,该域名就能接收邮件;如果没有MX记录,则只要域名本身能够解析即可;MX值为null则表示不接受任何邮件。
- 在Node环境中,
resolveMx()会在记录缺失时抛出异常,因此应直接处理ENODATA错误,而非假设返回空数组。