被忽视的Django生产陷阱:为什么ALLOWED_HOSTS通配符救不了你——DisallowedHost根因排查与中间件修复

TL;DR:你的 Django 项目突然开始报 DisallowedHost,日志里 Host 是一个奇怪的下划线 _,而你明明设了 ALLOWED_HOSTS = ['*']。问题出在 Django 的 Host 校验分两步------先做 RFC 1034/1035 域名合法性校验,通过才查 ALLOWED_HOSTS。_ 不是合法域名,连第一关都过不去。根治方案:一个 15 行的中间件,在 CommonMiddleware 之前拦截。


1. 事故现场

某天早上,含光博客的日志/邮件告警里出现了这样一个错误(关键信息已标注):

python 复制代码
DisallowedHost at /
Invalid HTTP_HOST header: '_'. 
The domain name provided is not valid according to RFC 1034/1035.

# ⬇ 三行关键证据
HTTP_HOST = '_'
HTTP_USER_AGENT = 'Hello from Palo Alto Networks, find out more about our scans...'
HTTP_X_FORWARDED_FOR = '203.0.113.1'

ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com']  # ← 明明设了通配符

第一反应ALLOWED_HOSTS = ['*'] 不是应该放行所有 Host 吗?怎么还报 DisallowedHost?


2. 根因:Django 的 Host 校验是两步,不是一步

翻 Django 源码 django/http/request.pyget_host() 方法的逻辑是:

python 复制代码
def get_host(self):
    host = self._get_raw_host()  # 从 HTTP_HOST / SERVER_NAME 取
        
    # 第一步:RFC 1034/1035 域名合法性校验
    host, port = split_domain_port(host)
    if host and not host_validation_re.match(host):
        raise DisallowedHost(f"Invalid HTTP_HOST header: {host!r}.")
        
    # 第二步:ALLOWED_HOSTS 白名单比对
    if not validate_host(host, settings.ALLOWED_HOSTS):
        raise DisallowedHost(f"Invalid HTTP_HOST header: {host!r}.")
        
    return host

关键发现 :第一步的 host_validation_re 正则在对 ALLOWED_HOSTS 生效之前就已经跑了。

_ 为什么通不过这个正则?因为 RFC 1034/1035 规定域名只能包含字母、数字和连字符(-),_(下划线)不在合法字符集中。所以 _ 直接在第一关被毙,根本走不到第二步的通配符 *

Host 值 第一步(RFC 校验) 第二步(ALLOWED_HOSTS) 结果
www.baidu.com 200
_.com ❌(含 _ 不会走到 DisallowedHost
纯数字 IP 1.2.3.4 200
单下划线 _ 不会走到 DisallowedHost
example.com 200

ALLOWED_HOSTS = ['*'] 保证的是第二步永远通过 ,但救不了第一步的 RFC 合规性检查


3. 这些畸形 Host 从哪来?

互联网扫描器(Shodan、Censys、Palo Alto Cortex Xpanse 等)在持续探测公网资产。它们发请求时可能会:

  • Host: 你的IP(用 IP 而非域名)
  • Host: _(占位符/探测标记)
  • Host: <script>alert(1)</script>(XSS 探测)
  • 完全不发 Host 头(某些 HTTP/1.0 客户端)

这些都不是攻击------只是常规资产扫描。但 Django 默认会为每个 DisallowedHost 记录 traceback + 发邮件给 ADMINS,日志和邮箱很快会被撑满。


4. 解决方案对比

方案 拦截点 副作用 推荐度
方案 A:Django 自定义中间件 应用层(Django) ⭐⭐⭐⭐⭐
方案 B:Nginx/Caddy 过滤 反向代理层 需 Web Server 配合 ⭐⭐⭐⭐
方案 C:修改 ALLOWED_HOSTS 配置 治标不治本(见第 2 节)
方案 D:忽略日志 核弹级------真正的攻击 Host 注入也会被掩盖

推荐方案 A,原因是:

  1. 不依赖 Web Server(含光博客的 Nginx 是 frp 穿透进来的,直接配 Nginx 有坑)
  2. 15 行代码,无外部依赖
  3. 精确控制------只拦截畸形 Host,正常请求零影响

5. 实现:BlockBadHostMiddleware

在 Django 项目的 middleware.py(或任何 middleware 文件)中添加:

python 复制代码
import re
from django.http import HttpResponse

# RFC 1034/1035 合法域名正则
# 匹配:example.com / sub.example.com / localhost / 纯 IPv4
# 不匹配:_ / _something / -bad.com
_HOST_RE = re.compile(
    r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?"
    r"(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$"
)


class BlockBadHostMiddleware:
    """在 CommonMiddleware 之前拦截无效 Host,避免 DisallowedHost traceback"""
    
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        host = request.META.get("HTTP_HOST", "")
        if host and not _HOST_RE.match(host.split(":")[0]):
            return HttpResponse("Bad Request", status=400)
        return self.get_response(request)

然后在 settings.pyMIDDLEWARE 列表最前面插入:

python 复制代码
MIDDLEWARE = [
    'yourapp.middleware.BlockBadHostMiddleware',   # ← 必须第一位!
    'django.middleware.security.SecurityMiddleware',
    # ... 其他中间件
]

为什么必须第一位? Django 中间件按列表顺序执行。CommonMiddleware(包含 get_host() 调用)如果在 BlockBadHostMiddleware 之前执行,拦截器就没机会跑了。


6. 验证

bash 复制代码
# 畸形 Host → 400
$ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8770/ -H "Host: _"
400

# 正常域名 → 200
$ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8770/ -H "Host: www.baidu.com"
200

畸形 Host 被静默拦截为 400,不再触发 traceback 和邮件告警。正常请求零影响。


7. 延伸:为什么 DNS 允许下划线但 HTTP Host 不允许?

这里有一个容易混淆的点:

  • DNS 层面 :SRV 记录(_http._tcp.example.com)和 DKIM/SPF 等确实使用下划线,RFC 2181 明确说"DNS 不对 label 内容做限制"
  • HTTP Host 头层面 :RFC 952 + RFC 1123(主机名规范)禁止下划线。Django 的 host_validation_re 遵循的是主机名规范,不是 DNS 规范

所以 _dmarc.example.com 作为 DNS 记录是合法的,但作为 HTTP Host 头传给 Django 就会被拒绝------除非你在前面放了这个中间件。


8. 总结

你看到的 真实原因 修法
DisallowedHost: '_' 扫描器发的畸形 Host 自定义中间件,返回 400 而不是抛异常
ALLOWED_HOSTS=['*'] 却没用 * 只管第二步,管不了第一步 RFC 校验 中间件插在 MIDDLEWARE 第一位
日志/邮箱被撑满 每个畸形请求都触发 traceback + 邮件 拦截后只返回 400,无 traceback

这个错误在生产环境非常常见------任何暴露在公网的 Django 项目都会被扫描器光顾。上述 15 行中间件可以永久解决这个问题。


本文代码已在 Django 5.2 + Gunicorn 生产环境中验证通过。如果你也有类似的排查经验,欢迎在评论区交流。

相关推荐
starrysky81021 小时前
Hermes Agent 的 70+ 工具不是硬编码的:一套自注册的注册表引擎 [04]
angular.js
巴勒个啦3 天前
Pinia 源码解析:响应式状态管理是如何工作的
angular.js
starrysky8104 天前
拆开 Hermes Agent 的引擎盖:八大子系统、37 个模块,一张地图讲清楚——底层系列开篇
angular.js
巴勒个啦6 天前
esbuild 插件实战:5个真实场景带你自定义构建流水线
前端·angular.js
李浚泽6 天前
Angular9 NG-ZORRO 9 复选框组合最佳实践
angular.js
starrysky8108 天前
AI 助手调试踩坑:5 轮瞎猜定位 4s budget 兜底路径(含 Hindsight 反思账本使用指南)
angular.js
LiuJun2Son8 天前
Angular 快速入门:服务和依赖注入
前端·javascript·angular.js
weixin_li152********9 天前
《Angular 中优雅地处理枚举值:Map + *ngIf as 替代多次 *ngIf》
javascript·vue.js·angular.js
LiuJun2Son10 天前
Angular 快速入门:从零搭建你的第一个应用
前端·javascript·angular.js