防范Django中send_mail的计时攻击
你遇到的这个问题其实是Web应用里很常见的时序攻击风险,确实不能小看——攻击者通过请求耗时差异就能探测出哪些邮箱已注册,进而针对性发起攻击。下面给你几个可行的解决方案,不需要依赖服务器配置的硬编码等待时间:
方案1:用异步任务处理邮件发送(最推荐)
把邮件发送逻辑从请求处理流程中剥离出来,交给异步任务队列去执行,这样不管邮箱是否存在,你的API请求都会立即返回,耗时完全一致。
在Django里最常用的就是Celery,步骤大概是这样:
- 配置好Celery和你的消息中间件(比如Redis或RabbitMQ)
- 把邮件发送逻辑封装成一个Celery任务:
from celery import shared_task from django.core.mail import send_mail from django.contrib.auth.models import User @shared_task def send_password_reset_email_task(email): try: user = User.objects.get(email=email) # 生成重置链接等逻辑 reset_link = "https://yourdomain.com/reset-password?token=xxx" send_mail( '重置你的密码', f'点击链接重置密码:{reset_link}', 'noreply@yourdomain.com', [email], fail_silently=False, ) except User.DoesNotExist: # 邮箱不存在,什么都不做 pass
- 在你的API视图里,不管邮箱是否存在,都调用这个任务:
from .tasks import send_password_reset_email_task from rest_framework.response import Response from rest_framework.decorators import api_view @api_view(['POST']) def password_reset_request(request): email = request.data.get('email') # 直接提交任务,不等待执行结果 send_password_reset_email_task.delay(email) # 统一返回提示,不泄露邮箱是否存在 return Response({"message": "如果邮箱已注册,我们会发送重置邮件"}, status=200)
这样一来,不管用户输入的邮箱是否存在,API的响应时间都是提交任务的那一点点时间,完全没有差异,从根源上解决了时序问题。
方案2:使用“伪发送”模拟真实耗时(适合不想引入异步队列的场景)
如果你暂时不想引入Celery这类异步工具,可以尝试在不需要发送邮件时,模拟真实邮件发送的IO操作耗时,而不是直接跳过。
Django的邮件系统支持自定义后端,你可以写一个后端,在需要“伪发送”的时候,执行和真实发送类似的网络操作但不实际投递邮件:
from django.core.mail.backends.smtp import EmailBackend class TimingSafeEmailBackend(EmailBackend): def send_messages(self, email_messages): sent_count = 0 for msg in email_messages: if hasattr(msg, 'fake_send') and msg.fake_send: # 模拟SMTP连接和发送的耗时:建立TCP连接到邮件服务器,不发送数据 import socket import time try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(5) # 连接到你的SMTP服务器地址和端口 sock.connect((self.host, self.port)) # 等待一小段时间模拟发送数据的耗时 time.sleep(0.1) sock.close() except Exception: pass sent_count += 1 continue # 真实发送邮件 if self._send(msg): sent_count += 1 return sent_count
然后在视图里,当邮箱不存在时,给邮件对象加上fake_send标记:
from django.core.mail import EmailMessage from rest_framework.response import Response from rest_framework.decorators import api_view from django.contrib.auth.models import User @api_view(['POST']) def password_reset_request(request): email = request.data.get('email') try: user = User.objects.get(email=email) msg = EmailMessage( '重置你的密码', '点击链接重置密码:xxx', 'noreply@yourdomain.com', [email] ) msg.send() except User.DoesNotExist: msg = EmailMessage( '重置你的密码', '点击链接重置密码:xxx', 'noreply@yourdomain.com', [email] ) msg.fake_send = True msg.send() return Response({"message": "如果邮箱已注册,我们会发送重置邮件"}, status=200)
记得在settings.py里配置这个自定义后端:
EMAIL_BACKEND = 'yourapp.backends.TimingSafeEmailBackend'
这个方法的好处是不需要引入额外依赖,耗时和真实发送接近,能有效避免时序差异。
方案3:自适应等待(折中方案)
如果你不想用异步也不想写自定义后端,可以尝试在内存中动态记录最近几次真实发送邮件的耗时,然后在不需要发送时等待这个平均耗时。
比如用一个全局的列表来记录最近10次发送的耗时:
import time from django.core.mail import send_mail from rest_framework.response import Response from rest_framework.decorators import api_view from django.contrib.auth.models import User # 用全局变量存储最近的发送耗时,最多存10条 email_send_times = [] @api_view(['POST']) def password_reset_request(request): email = request.data.get('email') start_time = time.time() try: user = User.objects.get(email=email) send_mail( '重置你的密码', '点击链接重置密码:xxx', 'noreply@yourdomain.com', [email], fail_silently=False, ) # 记录这次发送的耗时 elapsed = time.time() - start_time email_send_times.append(elapsed) # 只保留最近10条数据 if len(email_send_times) > 10: email_send_times.pop(0) except User.DoesNotExist: # 如果有记录的耗时,等待平均时间 if email_send_times: avg_time = sum(email_send_times) / len(email_send_times) elapsed = time.time() - start_time # 确保等待时间不为负数 time.sleep(max(0, avg_time - elapsed)) return Response({"message": "如果邮箱已注册,我们会发送重置邮件"}, status=200)
这个方法不需要外部依赖,也能自适应不同服务器的性能,但缺点是刚开始没有记录时会有差异,而且如果发送耗时波动大,效果会打折扣。
最后回答你问的:是否存在调用send_mail()但不实际发送邮件的方法?
有的,你可以暂时把Django的邮件后端改成django.core.mail.backends.dummy.EmailBackend,这个后端会接收邮件但不实际发送,不过它的耗时比真实发送短很多,所以单独用这个后端不能解决时序问题,得结合上面的等待逻辑才行。
内容的提问来源于stack exchange,提问作者ddjjaannggoo

