产品上线之后垃圾邮件防护这件事,绝大多数团队都是被收件箱逼着做的。上线第一天大家在盯着监控看 QPS 和错误率,第二天开始有人在群里发截图说"客服邮箱收到一堆莫名其妙的退信",第三天运维发现发信域名被对方拒了、注册接口的邮件验证码在半小时内发出去四千多封。这时候才回头去翻配置,才发现 SPF 记录是建站时随手抄的一段,DMARC 压根没配,联系表单连个频率限制都没有。我见过太多小团队卡在这个阶段:产品功能都还跑得挺好,唯独这几个看起来不起眼的入口,把域名信誉和钱包一起掏空了。
这篇东西想聊的就是这个场景——产品上线后垃圾邮件防护怎么从零搭起来。前面一半讲邮箱域名侧的配置,SPF、DKIM、DMARC 到底该怎么写、为什么这么写、写错了会怎样;后面一半讲应用侧的防爬和防刷,表单、注册、找回密码、发信接口这些"人为打开的口子"怎么一层层焊死。适合后端、运维、独立开发者,以及那种"公司没有专职安全岗、技术负责人自己顶上"的小团队。文中涉及的工具和配置都是通用方案,参数需要按自己的业务量调整,但思路可以直接拿去用。
1. 上线第三天收件箱被灌满:先定位垃圾邮件从哪个口子进来的
发现垃圾邮件问题的时候,最忌讳的动作是立刻去改配置。因为在没搞清楚入口之前,你改的任何一项都可能是无用功,更糟的是把正常业务改坏。我踩过一次典型的坑:某项目上线后收到大量退信,第一反应是"发信 IP 被拉黑了",赶紧换 IP、加白名单,折腾了一下午。最后一查日志,根本不是发信 IP 的问题,是站点上那个"推荐给朋友"的功能没有做任何校验,被人拿来当成免费的群发工具,用我们自己域名的信誉往外轰。域名信誉掉得比换 IP 快得多。
所以顺序一定是:先找到入口,再决定动哪一层。
1.1 三条最常见的泄露路径:公开邮箱、表单接口、被当成开放中继
垃圾邮件不是一个单一问题,它至少有三条完全不同的来路,处理手段也完全不一样。第一类是地址本身被爬走,比如官网页脚的 mailto 链接、联系我们的公开邮箱、招聘页留的 HR 邮箱,甚至代码仓库提交记录里的作者邮箱。这类问题不产生外发邮件,但会带来海量入站垃圾邮件,把客服和销售的工作流冲垮。
第二类是应用接口被人当发信机用。注册验证码、找回密码、邀请同事、订阅通知、联系表单自动回复——只要一个接口能触发一封邮件,它就是一个潜在的群发器。攻击者不一定是在攻击你,很多人只是在测试自己的脚本,顺手把目标填成了一批真实地址。这类问题最危险,因为烧的是你自己域名的信誉。
第三类是服务器被配置成了开放中继。老式的邮件服务如果对来源 IP 不做限制,任何人都能借它转发邮件。现在这种情况少了,但在一些自建邮件服务、或者把内部通知服务和邮件网关混在一起的架构里,仍然能见到。
| 入口类型 | 典型特征 | 快速自查方法 |
|---|---|---|
| 公开邮箱被爬 | 入站垃圾邮件多,外发日志正常 | 给公开邮箱加别名,观察别名是否开始收到垃圾 |
| 接口被滥用 | 发信量突增,收件人分散且不在用户库里 | 按接口路径统计发信量,看 Top 调用来源 |
| 开放中继 | 日志里出现陌生域名作为收件人 | 从外部 IP 测试未认证发信是否被拒 |
| 退信风暴 | 退信率突然升高,多为不存在的地址 | 检查是否有脚本用无效地址刷接口 |
提示:给自己留几个"专属别名"邮箱,比如只在某个页面出现的地址。一旦这个别名开始收到垃圾邮件,基本就能锁定是哪个页面被爬了,比翻日志快得多。
1.2 从邮件头反查:Received 链和 Authentication-Results 怎么读
判断一封垃圾邮件是"别人伪造你"还是"你自己发出去的",看邮件头是最直接的办法。打开原始邮件,从上往下读 Received 字段,每一条都是邮件经过的一跳,最上面的是最后一跳、最下面的是最开始的一跳。如果最底部那一条里的发信域名不是你自己的,那这封邮件就是伪造的;如果底部就是你自己的服务器,那说明确实是从你这边发出去的,问题出在接口层。
Authentication-Results 这个字段更关键,它会告诉你收件方对你这封邮件的验证结论:
Authentication-Results: mx.receiver.example; spf=pass smtp.mailfrom=bounce@example.com; dkim=pass header.d=example.com header.s=s1; dmarc=pass header.from=example.comspf=pass说明发信 IP 在你 SPF 记录授权的列表里;dkim=pass说明签名校验通过;dmarc=pass说明对齐关系成立。这三者里最常见的异常是spf=softfail或者dmarc=fail——意味着你可能有员工用个人邮箱代发、有第三方系统用你的域名发信但没进 SPF、或者某个子域名被人拿来伪造了。
还有一种情况是spf=none,说明对方根本没查到你的 SPF 记录。这通常意味着 DNS 记录没生效、或者记录写在了错误的域名上——比如你发信用的信封域是mail.example.com,但 SPF 只配在了example.com上。
1.3 日志侧的证据链:把滥用接口挖出来
邮件头能告诉你"是不是你发的",但查不到"是谁在调你的接口"。这一步必须靠访问日志。我习惯先用最粗暴的方式看一遍:
awk '{print $1" "$7" "$12}' access.log \ | sort | uniq -c | sort -rn | head -30这条命令按"IP + 路径 + UA"聚合,能在一分钟内看出异常。正常业务里,同一个 IP 对同一个接口的请求量通常是个位数,如果某个 IP 在半小时内对/api/auth/send-code请求了两千次,那就是明牌了。
接下来要确认的是"是单点还是分布式"。把统计维度换成纯路径:
grep "send-code" access.log \ | awk '{print $1}' | sort | uniq -c | sort -rn | awk '$1>50' | wc -l如果输出是几十,说明是分布式调用,封 IP 意义不大,必须上规则;如果输出是 1 到 3,那就简单了,直接封。这一步的判断直接决定后面选哪套方案,很多人跳过它,结果往死里配 IP 黑名单,效果为零。
还有一个容易忽略的信号:UA 为空的请求。正常浏览器和 App 都会带 UA,纯脚本经常漏掉。但反过来也不能一刀切,有些服务端调用和健康检查也不带 UA,所以这条规则只能作为参考信号,不能作为唯一判据。
2. 邮箱域名侧的硬防线:SPF、DKIM、DMARC 配不好等于没配
域名侧的配置有个特点:配了不代表生效,生效了不代表正确,正确了不代表有防护效果。我见过太多项目把三条 TXT 记录贴上去就宣布"邮件安全做完了",结果 DMARC 停在p=none,报告也没人看,等于给自己开了个观察模式然后忘了切。这一整套东西的正确用法,其实是分阶段推进的。
2.1 SPF 的十次 DNS 查询上限,以及几种典型的写错方式
SPF 的本质是一句话:"哪些 IP 有权用我这个域名发信。"写法很直白,一个 TXT 记录,以v=spf1开头:
example.com. TXT "v=spf1 ip4:192.0.2.10 include:_spf.mailprovider.example -all"ip4直接列 IP,include引用第三方邮件服务商的记录,a和mx表示允许域名的 A 记录和 MX 记录对应的 IP,最后的-all表示"除以上之外一律拒绝"。
这里有一个非常硬的限制:一条 SPF 记录在计算过程中最多只能触发 10 次 DNS 查询。include 一次算一次,a、mx、ptr、exists 也都算。超出之后结果是permerror,收件方会直接按失败处理。这件事在小团队里极其容易踩,因为每接入一个新的邮件服务商,就往里塞一个 include,塞到第六七个的时候悄悄爆掉,而且没有任何告警。
我通常建议:接入第三方服务之前先算一遍查询次数,把用量少的服务收敛到一个自建的转发层,而不是无限地堆 include。做一次统计的方法很简单:
dig +short TXT example.com | grep "v=spf1"拿到记录之后手动数,或者用脚本对每个 include 递归查询并计数。数量接近 8 的时候就该警惕了。
另一个高频错误是~all和-all的选择。~all是软失败,标记为可疑但照样投递;-all是硬失败,直接拒绝。上线初期为了不影响业务用~all可以理解,但很多项目一用就是一两年,等于没配。还有一个更隐蔽的问题:邮件列表转发会破坏 SPF,因为转发服务器的 IP 不在你的授权列表里,这时候如果用了-all,正常邮件会被拒。所以如果业务里有邮件列表或工单转发,建议配合 DKIM 一起上,让 DMARC 走对齐而不是死磕 SPF。
子域名也别忘。默认情况下 SPF 记录不会继承到子域名,mail.example.com、notice.example.com都需要单独考虑。如果某些子域名根本不发信,最干脆的做法是给它配一个v=spf1 -all,明确声明"这个域名不发邮件",这比留空安全得多。
2.2 DKIM:密钥长度、选择器命名与轮换节奏
SPF 校验的是 IP,DKIM 校验的是签名——信件在传输过程中有没有被改过、是不是真的从你这个域名签出来的。它的实现是生成一对密钥,私钥放在发信服务器上,公钥以 TXT 记录的形式挂在选择器._domainkey.子域名下面。
生成密钥的命令很标准:
opendkim-genkey -b 2048 -d example.com -s s1-b是密钥长度,-s是选择器名。密钥长度我建议直接上 2048,1024 位在今天已经不太合适了,而且很多收件方对 1024 位的签名会给更低的评分。代价是每封邮件的签名和验证会多花一点 CPU,但在正常的发信量下基本感知不到。
选择器的命名有个实用技巧:带上日期,比如s202405、s202511。原因是密钥轮换的时候,新旧两套公钥需要同时在线一段时间,否则轮换瞬间正在传输中的邮件会验签失败。带日期的选择器让"哪把钥匙是什么时候启用的"一目了然,半年后回来看也不会一脸懵。
轮换的正确姿势是三步走:先在 DNS 里加上新选择器的公钥,等 TTL 过期(一般等一个小时以上,稳妥起见等 24 小时);再把发信端切换到新选择器并开始双签一段时间;最后观察签名通过率稳定后,删掉旧选择器的公钥。跳过中间的双签阶段是最常见的错误,尤其在用 CDN 或者多区域部署的时候,配置下发不同步,一部分节点还在用旧密钥,就会出现间歇性的验签失败。
还有一个细节是签名头范围。默认的签名头列表通常已经够用,但如果业务里用到了自定义头(比如某些工单系统会加X-Ticket-ID),需要确认它是否在签名范围内,否则中间环节修改这个头会导致签名失效。
2.3 DMARC 从 p=none 到 p=reject 的灰度推进
DMARC 是把 SPF 和 DKIM 的结果"翻译"成一条处置策略的机制。它的记录长这样:
_dmarc.example.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc-report@example.com; ruf=mailto:dmarc-forensic@example.com; pct=100; adkim=s; aspf=s"p是策略,none只观察不处置,quarantine建议进垃圾箱,reject直接拒收。rua是聚合报告的接收地址,每周会收到一堆 XML,里面列着谁在用你的域名发信、从哪个 IP 发、通过率如何。
我的推进节奏一般是这样的:
| 阶段 | 策略 | 持续时间 | 重点关注 |
|---|---|---|---|
| 观测期 | p=none | 2 到 4 周 | 报告里出现的所有发信源是否都是自己的 |
| 收敛期 | p=quarantine; pct=25 | 1 到 2 周 | 正常邮件是否被误判 |
| 扩大期 | p=quarantine; pct=100 | 2 周 | 业务方是否有投诉 |
| 收口期 | p=reject | 长期 | 持续监控报告 |
pct参数表示策略的适用百分比,传统实现里用它做灰度,逐步从 25 加到 100。要注意这个参数在较新的规范讨论里已经被弱化,不同收件方的支持程度不一,所以别把灰度完全押在它身上——真正的灰度靠的是观察报告和业务反馈。
观测期最需要警惕的是报告里出现的"陌生发信源"。常见的有:CRM 系统用你的域名代发通知、客服工单系统、某个离职同事配的自动化脚本、甚至是市场部门自己买的邮件营销工具。这些如果不提前收编进 SPF 或者改用子域名,一旦切到reject,业务邮件会直接消失,而且不会有明显报错——发信方看到的是"已投递",收件方那边根本没收。
提示:营销邮件、通知邮件、事务邮件建议用不同子域名发。子域名出问题不会连累主域名,而且 DMARC 可以用
sp=参数给子域名单独指定策略,隔离风险。
2.4 反向解析、退信地址与发信 IP 的信誉积累
这三件事经常被归到"运维细节"里被忽略,但它们对到达率的影响一点不比 SPF 小。
反向解析就是 PTR 记录,让 IP 反查出来的域名和你 HELO 里报的域名一致。很多收件方会把 PTR 缺失或者不匹配当成一个负面信号。这件事在部分服务商那边需要提交工单才能改,所以上线前就该确认好,别等出问题再补。HELO 域名也建议用mail.example.com这种专门的信邮子域名,而不是服务器的主机名。
退信地址要真的能收信。postmaster@和abuse@这两个地址在规范上是必须可达的,很多收件方在判定信誉时会去探测。如果这两个地址是 550 不存在的,信誉分直接就下去了。另外,退信地址(Return-Path / 信封发件人)最好独立于 From 地址,用一个专门的bounce@,这样退信不会污染主邮箱,也方便做退信分类处理。
新 IP 一定要预热。突然从一个全新 IP 发出几千封邮件,绝大多数收件方会直接限流或者拒收。稳妥的做法是第一周每天几百封,第二周翻倍,逐步爬升,同时盯着聚合报告里的通过率。如果业务上等不了,那就老老实实先用成熟的发信服务,把域名信誉养起来再自建。
3. 表单、注册、找回密码:把发信入口一个个焊死
域名侧是"防伪造",应用侧是"防滥用",两者缺一不可。我见过域名配置做得非常规范的项目,SPF 严格、DMARC 到 reject、DKIM 2048 位全齐,结果注册接口一个验证码都不需要,脚本一分钟能发三百封验证邮件。防护做在了外面,门却是敞开的。
应用侧的思路不是"上一个验证码",而是分层。每一层的成本、对用户体验的影响都不一样,正确的做法是把它们叠起来,而不是选一个最狠的用到底。
3.1 先量出正常流量基线,再谈阈值
所有限流规则的第一个问题都是"阈值定多少"。这个数字不能拍脑袋,必须从自己的数据里量出来。方法很朴素:找一个业务正常的工作日,把发信接口的调用量按分钟和按小时分别统计一遍。
grep "POST /api/auth/send-code" access.log \ | awk '{print substr($4,2,17)}' \ | sort | uniq -c | sort -rn | head -20这段会输出每分钟的调用峰值。看三个数字:P50(中位数)、P95、峰值。限流阈值一般定在 P95 的 2 到 3 倍,既能挡住异常,又不会误伤正常时段的高峰。比如 P95 是每分钟 30 次,那全站阈值可以定在 80 到 100 次每分钟,同时再叠加单 IP 的阈值(比如每分钟 3 次)。
这里有个容易被忽略的点:基线要按接口分开量。找回密码和注册验证码的调用量差一个数量级,用同一个阈值必然有一边不合适。我一般会按接口建不同的限流区,而不是全局一个池子。
另外一个反直觉的建议:阈值宁可松一点,先跑一周看误伤。因为限流误伤的代价是真实用户收不到验证码,客服会炸;而限流偏松的代价只是多几封垃圾邮件,两者的成本完全不对称。
3.2 蜜罐字段加时间戳:几乎零成本的第一道筛子
在所有防刷手段里,蜜罐字段的性价比是最高的。原理很简单:在表单里塞一个正常用户看不见、也不会去填的输入框,脚本在批量填表时通常会无差别地把所有字段都填上,一旦这个隐藏字段有值,就可以判定是机器。
前端这么写:
<form method="post" action="/api/contact"> <input type="text" name="email" required /> <textarea name="message" required></textarea> <div style="position:absolute;left:-9999px;top:-9999px" aria-hidden="true"> <input type="text" name="website" tabindex="-1" autocomplete="off" /> </div> <input type="hidden" name="form_ts" value="1710000000" /> <button type="submit">提交</button> </form>注意几个细节:用position:absolute移出视口而不是display:none,因为部分脚本会跳过display:none的字段;加tabindex="-1"防止键盘用户 Tab 到它;加autocomplete="off"防止浏览器自动填充;外层容器加aria-hidden="true"让读屏软件忽略。
后端校验同时看两件事:隐藏字段有没有值、从渲染到提交花了多长时间。
import time MIN_FILL_SECONDS = 3 def is_likely_bot(payload): if payload.get("website"): return True try: ts = int(payload.get("form_ts", 0)) except (TypeError, ValueError): return True if ts <= 0 or time.time() - ts < MIN_FILL_SECONDS: return True return False时间戳校验拦住的是"秒填"的脚本。真实用户从打开表单到填完提交,通常要十几秒到几分钟;机器的间隔往往是毫秒级。MIN_FILL_SECONDS定 3 秒比较稳妥,定太高会误伤那些用密码管理器秒填的真实用户——这也是我踩过的坑,一开始设成 10 秒,结果被用户投诉"表单提交不了"。
蜜罐的局限也要说清楚:针对性的脚本会读 HTML,能识别出隐藏字段并主动跳过。所以它只能当第一层,不能当唯一一层。但对绝大多数"顺手扫一遍"的批量工具,这一层已经能挡掉七八成。
3.3 验证码的取舍:什么时候该上,什么时候是在劝退真实用户
验证码是有效手段,但它对转化率的影响很直接。我的原则是按风险分级触发,而不是所有请求都上。
具体做法是给每个请求算一个风险分:单 IP 短时间内的历史调用次数、该邮箱或手机号是否是首次出现、UA 是否可疑、请求是否缺少正常的 Referer、蜜罐是否命中。分数低的直接放行,分数中等的上无感人机校验,分数高的直接拒绝并记录。
这样设计的好处是,绝大多数真实用户完全感知不到验证码的存在,而脚本在触发阈值之后会立刻撞墙。风险分不需要多复杂,四五个信号加权就能覆盖大部分场景。下面是一个简化的判断逻辑:
| 信号 | 权重 | 说明 |
|---|---|---|
| 同 IP 10 分钟内已发 3 次以上 | 高 | 大概率是脚本 |
| 目标邮箱为首次出现 | 中 | 新用户正常,但也可能是批量目标 |
| 缺少 Referer 或 Referer 异常 | 中 | 直接调接口的典型特征 |
| 蜜罐命中 | 极高 | 直接拒绝,不进验证码流程 |
| 请求间隔呈均匀分布 | 高 | 机器节奏 |
关于验证码类型,滑块类对脚本的抵抗力一般,因为轨迹可以被模拟;行为类(比如点击、拖动、语义判断)效果好一些,但接入成本和用户耐心成本都高。我的建议是:发信类接口用行为校验,登录类接口用滑块或一次性令牌就够了。因为发信接口被滥用的后果(烧域名信誉、烧短信费)远比登录被撞库严重。
还有一条容易被忽略:验证码本身要有服务端校验,且一次性使用。见过不少实现是前端校验通过就直接放行,接口层完全不验,那等于没做。
3.4 Nginx 限流和 Redis 计数的具体配置
限流要分两层做:边缘层挡洪水,应用层做精细控制。边缘层用 Nginx 就够了,配置不复杂:
limit_req_zone $binary_remote_addr zone=sendcode:10m rate=3r/m; limit_req_zone $binary_remote_addr zone=formpost:10m rate=10r/m; limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m; server { location = /api/auth/send-code { limit_req zone=sendcode burst=2 nodelay; limit_req_status 429; limit_conn conn_per_ip 5; proxy_pass http://backend; } location = /api/contact { limit_req zone=formpost burst=5 nodelay; limit_req_status 429; proxy_pass http://backend; } }rate=3r/m是每分钟 3 次,burst=2允许 2 个突发请求排队,nodelay表示突发请求立即处理而不是延迟。这里有个坑:limit_req_zone的 key 用$binary_remote_addr而不是$remote_addr,前者是二进制格式,内存占用小得多,10m 的空间能存更多 IP。另外,如果站点在 CDN 后面,$remote_addr拿到的是 CDN 节点的 IP,所有用户会共用一个桶——这时候必须从X-Forwarded-For里取真实 IP,并且确保只有可信来源的请求头才被采信,否则攻击者随手伪造一个 XFF 就能绕过限流。
应用层用 Redis 做更细的计数,比如按"邮箱 + 时间窗口"限制:
-- KEYS[1] = 计数键,ARGV[1] = 上限,ARGV[2] = 窗口秒数 local key = KEYS[1] local limit = tonumber(ARGV[1]) local ttl = tonumber(ARGV[2]) local current = redis.call('INCR', key) if current == 1 then redis.call('EXPIRE', key, ttl) end if current > limit then return 0 end return 1用 Lua 脚本是为了保证 INCR 和 EXPIRE 的原子性,避免第一次请求在设置过期时间之前进程挂掉导致键永不过期——这个 bug 一旦出现,那个邮箱就再也收不到验证码了,而且极难排查。
计数键的设计建议是rate:sendcode:ip:{ip}和rate:sendcode:mail:{sha1(email)}两套并行。前者防单点,后者防"同一目标被反复轰炸"。邮箱做哈希是为了不在 Redis 里存明文个人信息,也方便做统一的键长度管理。
4. 防爬的着力点不在封 IP:识别脚本的"非内容"特征
前面那套限流是"按量"控制,它有个天花板:当攻击变成分布式、每个 IP 只发两三次的时候,任何基于单 IP 的阈值都失效。这时候要么扩大统计维度(按 UA、按指纹),要么去找脚本"装不像"的地方。我更倾向于后者,因为脚本伪装成本最低的部分恰恰是内容,而最难伪装的是那些协议级、网络级的细节。
4.1 Header 顺序、UA 与 TLS 指纹:机器很难伪装的部分
一个真实的浏览器发请求,Header 的顺序是固定的:Host、Connection、Cache-Control、User-Agent、Accept、Accept-Language、Accept-Encoding,然后是 Cookie 等。这个顺序由浏览器内核决定,不是随机的。而用脚本库发请求时,Header 顺序通常就是代码里字典的顺序,或者按字母排序,和真实浏览器明显不同。
这个信息在应用层拿不到(多数框架会把 Header 解析成无序的哈希),需要在网关层或者 Nginx 里处理。Nginx 有一个比较隐蔽的用法:用$http_变量按需读取,但它同样不保留顺序。要真正拿到顺序,得用 Lua 模块或者放到应用的前置代理里做。如果嫌麻烦,一个折中方案是在边缘层计算一个简单的指纹哈希,把能拿到的信号(Header 集合、UA、Accept 系列)拼起来做散列,作为限流的 key 之一。
UA 与能力不符也是高频信号。常见的例子:UA 声称是某个移动端浏览器,但请求里没有任何移动端该有的特征;UA 声称是 Chrome 最新版,但 TLS 握手的特征是老版本;页面加载了 JS,但这个客户端的请求里从未出现过任何后续的异步请求(说明它不执行 JS)。最后这一条在防爬里特别好用——正常浏览器渲染完页面一定会带回静态资源请求和埋点请求,纯 HTTP 脚本通常只请求 HTML 本身。
TLS 指纹(JA3 之类的方案)效果很好,但部署成本不低,需要支持在握手阶段提取特征。小团队我一般不建议一步到位上 TLS 指纹,先把 Header 和应用层行为做到位,收益已经能覆盖八成场景。
4.2 请求节奏比请求频率更能说明问题
人类操作是不均匀的。点一个按钮,可能隔两秒,也可能隔二十秒,中间还会停下来看别的页面。而脚本的请求间隔往往非常规整:每一轮都是精准的 1000 毫秒,或者整点启动、每五分钟一轮。
这个特征在日志里一眼就能看出来。取某个 IP 的请求时间戳,算相邻之间的差值:
grep "192.0.2.45" access.log \ | awk '{print $4}' \ | sed 's/\[//' \ | awk -F: '{print $2*60+$3}' \ | awk 'NR>1{print $1-prev} {prev=$1}' \ | sort -n | uniq -c | sort -rn | head如果输出的差值集中在某一个数值上,间隔的方差极小,那基本就是机器。真实用户的请求间隔差值是散乱的,几乎没有重复值。
还有一个更简单的判据:请求的路径集合。正常访问一个站点的用户,会带着 Referer、会请求 CSS 和 JS、会请求 favicon。脚本通常直奔目标接口,一个静态资源都不碰。在 Nginx 里统计一下单个 IP 的静态资源请求占比,占比为零且接口请求量不低的,可以直接降级处理。
这类判定的价值在于,它不依赖"频率"这个容易被分布式稀释的指标,而依赖"模式"这个很难伪装的属性。把节奏特征和频率阈值叠加,拦截率会明显上升,误伤率也不会跟着涨。
4.3 邮件轰炸与短信轰炸要单独设计
这两种攻击和一般的垃圾邮件不太一样,它的目的不一定是给你发广告,而是让目标用户被骚扰。典型的场景是:攻击者拿一批邮箱地址去调你的"发送验证码"接口,用户一晚上收到几十封邮件,最后把你的域名加入黑名单。短信的版本更贵——每发一条都是真金白银。
防护的要点在"目标维度"而不是"来源维度"。核心规则有这么几条:
同一个目标(邮箱或手机号)在 24 小时内最多触发 N 次发送,超过之后静默丢弃,不提示"发送过于频繁"以外的任何信息。这里为什么不提示更详细的原因?因为详细的错误提示本身就是信息泄露,攻击者可以借此判断哪些地址是有效用户。
验证码必须绑定用途和有效期。注册的验证码不能用来找回密码,5 分钟内失效,验证成功后立即作废。很多项目为了省事用一个全局验证码池,结果被复用攻击。
发送接口要做"前置状态校验"。比如找回密码接口,如果邮箱不存在,接口本身应该走正常流程但不真的发信(避免枚举),而不是直接返回"该邮箱未注册"。这个细节既防枚举又防轰炸。
再加一条兜底:日发信总量熔断。给整个域名的日发信量设一个上限,超过之后自动降级为"仅发送高优先级邮件",同时触发告警。这条规则看起来粗暴,但它是最后一道保险——真要出事了,至少不会无限量地烧下去。我自己经历过一次,就是因为没有熔断,一个未做校验的接口在四小时里发出去六万多封,事后清理退信和申诉花了两周。
5. 上线后的长期运营:日志、告警与封禁的边界
防护配置写完那一刻,工作才刚开始。垃圾邮件的手法会变,业务流量会长,配置会被人误改——这三件事里任何一件发生,静态的规则都会失效。所以要留出运营的空间。
5.1 该留哪些日志、留多久、怎么查
最基础的三个日志:访问日志、发信日志、验证失败日志。访问日志记 IP、路径、UA、状态码、响应时间;发信日志记收件地址(做哈希或脱敏)、触发接口、来源 IP、发信结果、退信原因;验证失败日志记 SPF/DKIM/DMARC 的判定结果和对方域名。
保留时长我一般建议:访问日志 30 天,发信日志 90 天,退信记录 180 天。退信记录留得久是有原因的——域名信誉的恢复是慢过程,几个月后回看退信趋势才能判断恢复情况。
查询这件事不要依赖"出事了再登服务器 grep"。至少做一个按小时聚合的统计表,把发信量、退信率、验证失败率、限流触发次数四个指标存下来。这四个数字就是防护体系的体温计,正常情况下应该平稳,任何一项突变都值得看一眼。
5.2 误封的代价与白名单机制
封禁是件很爽的事,直到你封错了真实客户。我见过最尴尬的一次,是某公司的办公网出口 IP 被整体拉黑,导致整个公司上百号人无法登录,客服电话打了两天才找到原因。
所以封禁一定要分级。IP 级别的封禁只针对"明确恶意"的行为,比如一小时几百次请求、命中蜜罐且频率高、UA 与行为严重不符。对于"可疑但不确定"的,只做限流不做封禁。同时必须有白名单机制,而且要能自服务——用户申诉之后,运营能在后台快速加白,而不是改配置文件重启服务。
另一个经验是:封禁要有自动过期。手动封禁最怕的就是封完忘了,半年后这个 IP 换了个主人,成了无辜受害者。所有封禁记录都带过期时间,24 小时、7 天、30 天分档,到期自动解除,需要延长的再手动续。
还有一条:给内部系统和监控探针留固定的白名单网段,并且这条白名单要在限流规则生效之前判断。否则监控系统的探针会被自己的限流挡住,你会收到一堆"服务不可用"的假告警。
5.3 一次凌晨的应急处理复盘
说个具体的例子。某项目上线第二周,凌晨两点告警响了:发信失败率从 1% 飙到 60%,退信数量每分钟几十封。值班同学的第一个动作是去看发信服务商的状态页——正常。第二个动作是查发信日志,发现失败集中在某一个域名,对方的返回是"发信方信誉不足"。
这时候有两个错误方向。一个是立刻去改 SPF、DKIM,希望通过"配置更严格"来提升信誉——但信誉是个累积指标,改配置当天不会有效果。另一个是疯狂发测试邮件验证——这反而会加重问题。正确的做法是先把发信的源头掐住:临时关掉所有非事务性邮件(营销、通知、订阅),只保留验证码和密码重置,把发信量砍到原来的十分之一。
砍完之后再看访问日志,十分钟内就定位到了:/api/invite这个接口在被人批量调用,参数里的收件人是一串乱序邮箱,来源是几百个分布在多个网段的 IP,每个 IP 只请求十几次,单 IP 限流完全没触发。这就是典型的分布式滥用。
处理顺序是这样的:先给这个接口加"邀请人必须是已登录且有正常使用记录的用户"这个业务前置条件,从逻辑上堵死匿名调用;再给被邀请的邮箱加去重和 24 小时窗口,同一个目标只发一次;然后对历史数据做一次清理,把已经发出去的退信地址拉进抑制列表,后续不再尝试投递。做完这三步,发信量当天就恢复了正常。
后续的收尾工作反而更花时间:联系收件方申诉、观察聚合报告里的通过率恢复情况、把这次的事件写成规则补进监控。整个恢复过程花了两周多,期间所有事务邮件的到达率都受影响。这件事之后我给自己定了一条规矩:任何能触发邮件发送的接口,上线前必须过一遍"匿名能不能调、能不能改参数、有没有次数上限"这三个问题。三问过不了,就不许上。
现在还保留着一个习惯:每次新功能上线,我会用一个脚本去扫一遍所有带发信逻辑的路由,确认它们都有前置校验和频率限制。这个脚本本身很简单,就是遍历路由表,对每个发信接口发几个请求看响应,但它在过去两年里帮我拦下了至少四次"差点上线"的疏漏。防护这件事,靠的不是配置写得多漂亮,而是每次改动都记得问一句"这个口子会不会被人拿来发信"。