You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

防范Django中send_mail的计时攻击

解决Django中REST API因邮件发送耗时导致的账户存在泄露问题

你遇到的这个问题其实是Web应用里很常见的时序攻击风险,确实不能小看——攻击者通过请求耗时差异就能探测出哪些邮箱已注册,进而针对性发起攻击。下面给你几个可行的解决方案,不需要依赖服务器配置的硬编码等待时间:

方案1:用异步任务处理邮件发送(最推荐)

把邮件发送逻辑从请求处理流程中剥离出来,交给异步任务队列去执行,这样不管邮箱是否存在,你的API请求都会立即返回,耗时完全一致。

在Django里最常用的就是Celery,步骤大概是这样:

  1. 配置好Celery和你的消息中间件(比如Redis或RabbitMQ)
  2. 把邮件发送逻辑封装成一个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
  1. 在你的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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.30 06:47:37