Django-生产环境中的安全
这篇讲什么
- 生产环境的安全不是一个开关,而是四层各管一段:网络层(防火墙、安全组)、传输层(TLS 与 HSTS)、应用层(Cookie、CSRF、XSS、注入、限流)、数据层(密码哈希、字段加密、日志脱敏)。
- 这篇把能在本机跑的都跑了一遍:TLS 用自签证书真起了一次 HTTPS,CSRF 四种请求的拒绝原因拿到原文,
limit_req数出了 200 和 503 的个数,密码校验器和哈希迭代次数都是实测值。 - 只有云安全组、CDN、Let’s Encrypt 真实签发这三类必须依赖外部环境的,按官方文档写步骤并标注未在本机验证。
- 实测环境:WSL2 Ubuntu 26.04.1 LTS + Python 3.14.4 + Django 5.2.19 LTS + MySQL 9.7.4 + Gunicorn 26.2.0 + Nginx 1.28.3 + OpenSSL 3.x + ufw。
- 一句话原则:Django 的默认值是「开发友好」而不是「生产友好」,安全项基本都要显式打开,而且要和 HTTPS 一起打开才有意义。
先分清四层防线
| 层 | 管什么 | Django 能做的 | 必须靠外面的 |
|---|---|---|---|
| 网络层 | 谁能连到机器 | 无 | 云安全组、ufw/firewalld、只监听 127.0.0.1 |
| 传输层 | 链路是否加密、是否强制加密 | SECURE_* 一堆开关 |
证书签发、TLS 参数、443 监听 |
| 应用层 | 请求是否可信 | CSRF / XSS 转义 / ORM 参数化 / Cookie 属性 | WAF 规则、限流兜底 |
| 数据层 | 数据泄露了怎么办 | 密码哈希、sensitive_* 脱敏 |
字段级加密、备份加密、密钥托管 |
老笔记里的「硬件防火墙 / WAF / 系统防火墙」三分法仍然成立,补一层含义:
- WAF(Web Application Firewall)基于规则匹配,拦 SQL 注入、XSS、扫描器、web shell 上传,属于「应用层的粗筛」,不能替代 Django 自己的转义与参数化。
- 系统防火墙只决定端口可达性,它拦不住一个合法端口上的恶意请求。

系统防火墙:ufw 实测
默认策略是「拒入放入」,不是「允许所有」
老笔记写的是「开启 ufw 后默认允许所有连接通讯」,这句反了。实测(Ubuntu 26.04,ufw 当前未启用):
1 | $ sudo ufw status verbose |
1 | Status: inactive |
也就是说一 enable 就是「默认拒绝所有入站」,所以开之前必须先放行 SSH,否则第一个把自己关在外面。

2.2 可以先写规则、后启用(实测安全做法)
ufw 未启用时加规则不会影响任何流量,可以用 show added 先审一遍再 enable:
1 | $ sudo ufw allow 80/tcp |
1 | Added user rules (see 'ufw status' for running firewall): |
limit 22/tcp 是防爆破 SSH 的推荐写法(自带连接频率限制)。审完再:
1 | $ sudo ufw enable # 会提示可能中断现有连接,输入 y 确认 |
数据库端口对外暴露才是真问题
实测本机 MySQL/Redis/PostgreSQL/Mongo 的监听地址(ss -ltn):
1 | 127.0.0.1:6379 # Redis 只回环 |
优先级应该是:先让服务只监听 127.0.0.1,再谈防火墙规则。同机的 Django 与 MySQL 之间根本不需要公网可达。
云上的安全组(阿里云/AWS 控制台配置入方向规则)属于外部平台操作,未在本机验证,思路一致:只开 80/443(和必要的运维端口),数据库端口一律不对公网开放。
HTTPS:证书、跳转与 HSTS
本机先用自签证书把链路跑通
真证书要域名和公网验证,本机做不了;但 TLS 本身可以真跑。用 openssl 签一张 30 天的:
1 | $ openssl req -x509 -newkey rsa:2048 -nodes \ |
Nginx 侧起一个 TLS server,并把 X-Forwarded-Proto 覆盖给后端:
1 | server { |
实测结果:
1 | https -> 200 ssl_verify_result=18(18=自签,0=受信) |
ssl_verify_result 不是 0 很正常,浏览器同样会报证书不受信;换成 Let’s Encrypt 的证书就变 0。
3.2 SECURE_SSL_REDIRECT:301 是后端给的
后端单独起一个开了跳转的 gunicorn(8021),再用明文请求打它:
1 | http 直连 8021(SECURE_SSL_REDIRECT=True) -> 301 |
生产上通常让 Nginx 做跳转(省一次后端往返),Django 这个开关留给「后端会被直连」的场景兜底。
HSTS 只在 HTTPS 响应里出现
这是最容易配错的一条,实测同一个视图两种请求:
1 | http 请求 HSTS = (没有) |
配置:
1 | SECURE_HSTS_SECONDS = 31536000 # 一年起步 |
includeSubDomains 和 preload 都是难撤回的承诺:一旦进预加载列表,任何子域没上证书就会直接打不开。所以顺序是:先只给主域短 max-age 观察,再逐年加长。
另外 HSTS 要求响应必须来自 HTTPS,DEBUG=True 时 Django 也不会给这个头(官方检查项 W004 就是提醒你别在没上 HTTPS 时乱开)。
3.4 Let’s Encrypt 与 certbot(按官方文档,未在本机验证)
Let’s Encrypt 的证书有效期 90 天,靠 certbot 自动续期。两种域名验证方式:
| 方式 | 原理 | 适用 |
|---|---|---|
| webroot | 往站点根写 /.well-known/acme-challenge/ 验证文件,CA 通过域名来取 |
已有 Nginx/Apache 在跑,最常用 |
| standalone | certbot 自己临时起一个 web server 占 80 端口 | 机器上还没有 Web 服务器,或能短暂停站 |
webroot 方式需要 Nginx 放行验证路径:
1 | location /.well-known/acme-challenge/ { |
1 | $ nginx -s reload |
证书目录 /etc/letsencrypt/live/example.com/ 里四个文件的分工:
| 文件 | 内容 | Nginx 用哪个 |
|---|---|---|
cert.pem |
服务器证书 | 不单独用 |
chain.pem |
中间证书链 | 不单独用 |
fullchain.pem |
cert.pem + chain.pem |
ssl_certificate |
privkey.pem |
私钥(权限 600) | ssl_certificate_key |
续期与检查(官方推荐做法):
1 | $ certbot renew --dry-run # 演练续期,不改真证书 |
Debian/Ubuntu 上官方推荐用 snap 装:
1 | $ sudo apt update && sudo apt install snapd |
Cookie 与会话:默认值实测
先看清 Django 给了什么
实测 proj.settings.prod 里的值(未显式配置时):
1 | CSRF_COOKIE_DOMAIN = None |
SESSION_COOKIE_AGE 两周意味着会话 cookie 默认活很久;要「关浏览器就登出」设 SESSION_EXPIRE_AT_BROWSER_CLOSE = True,要「30 分钟无操作自动登出」设 SESSION_SAVE_EVERY_REQUEST = True 并把 AGE 调小。
CSRF_COOKIE_HTTPONLY=False 是故意的:AJAX 上传要从 JS 读 csrftoken 塞进 X-CSRFToken 头。如果你的前端不需要读它,可以设成 True 收紧。
4.2 Secure=True 必须和 HTTPS 一起上
实测把 SESSION_COOKIE_SECURE / CSRF_COOKIE_SECURE 打开后的行为:
1 | http GET /form/ -> 200 cookie: 一个都没有 |
也就是说在还没上 HTTPS 的站点上打开这两个开关,等于把登录和 CSRF 全打断。正确顺序是:证书就位 → Nginx 强制跳转 → 再打开 Secure。
SameSite 默认 Lax 通常够用(能挡住绝大多数跨站 POST),只有确实需要跨站带 cookie 的嵌入场景才降到 None,而 None 必须搭配 Secure,浏览器才会存。
CSRF:Django 到底在校验什么
四种请求的实测结果
用 Client(enforce_csrf_checks=True) 模拟真实浏览器行为(先 https 拿到 csrftoken,再 POST):
| 请求 | 状态码 | Django 记下的拒绝原因 |
|---|---|---|
带 token,但没有 Origin/Referer |
403 | Forbidden (Referer checking failed - no Referer.) |
Origin: https://evil.example.com |
403 | Forbidden (Origin checking failed - https://evil.example.com does not match any trusted origins.) |
Referer: https://evil.example.com/form/ |
403 | Forbidden (Referer checking failed - https://evil.example.com/form/ does not match any trusted origins.) |
Origin: https://probe.local |
200 | 放行,返回 posted: x |
关键结论:现代 Django 除了比对 token,还会比对 Origin/Referer。所以「表单里有 {% csrf_token %} 就安全了」并不完整——把站点部署到另一个域名下、或者用 CSRF_TRUSTED_ORIGINS 配错,都会 403。
5.2 CSRF_TRUSTED_ORIGINS 必须带 scheme
多域名、CDN 回源、内网跳转都会撞上 502/403。写法(实测生效):
1 | CSRF_TRUSTED_ORIGINS = ["https://probe.local", "https://admin.example.com"] |
1 | CSRF_TRUSTED_ORIGINS = ['https://probe.local'] |
三个细节:必须写全 scheme://host(可以带端口);只写 probe.local 不生效;用 env_list 从环境变量给,省得每个环境改代码。
模板与 AJAX 两种带法
1 | <form method="post" action="/form/"> |
1 | // AJAX:从 cookie 读出来放进请求头 |
@csrf_exempt 是给「确实不需要 session 的接口」用的逃生口,别拿它当解决 403 的常规手段——403 通常说明 Origin/域名配置错了。
XSS:转义是默认开的,问题都在「越狱」
同一段恶意载荷 <img src=x onerror="alert(1)">,五种写法的实测输出:
1 | 默认自动转义 : <p><img src=x onerror="alert(1)"></p> |
也就是说 Django 模板默认是安全的,被 XSS 打的三条路都是主动关掉了转义:
|safe过滤器。{% autoescape off %}或mark_safe()包了用户输入。- 富文本字段本身要允许 HTML,却直接塞进模板——这种必须过白名单清洗(Django 不提供内置清洗,官方建议用第三方,如
bleach/django-bleach;本机未装,属备选)。
响应头侧实测(SecurityMiddleware 默认输出):
1 | X-Content-Type-Options: nosniff |
CSP(Content-Security-Policy)Django 没有内置开关,要靠 Nginx add_header Content-Security-Policy ... 或第三方中间件;这是防 XSS 的最后一道兜底,值得上。
七、SQL 注入:ORM 帮你挡了,raw 没帮你挡
同一条 payload ' OR '1'='1,两种写法的结果(实测):
1 | 参数化 -> 命中 0 行(预期 0) |
对应代码:
1 | from django.db import connection |
几个容易忽略的点:
- 参数化用
%s占位符 + 第二个参数列表,不要用 f-string 或%自己拼。 - 列名、表名、排序方向这类结构位置不能参数化,必须白名单校验:
1 | ALLOWED_SORT = {"name", "hits", "created_at"} |
Model.objects.raw()、QuerySet.extra()、connection.cursor().executemany()同样要传参数,别拼字符串。__in传字符串会被当成逗号分隔的序列,属于逻辑 bug 而不是注入,但排查时最容易混淆。- 数据库账号本身也要最小权限(不给
DROP、不给FILE),这样即使应用层被绕过,损失也可控。
密码:哈希、校验器、过期与登录失败
默认哈希算法实测
1 | 算法 = pbkdf2_sha256 | 迭代 = 1000000 | 盐 = oZkAWZLFwrAGNCiMvNUUrB | 校验 = True |
哈希串是 算法$迭代$盐$摘要 四段,Django 会按 PASSWORD_HASHERS 顺序识别旧哈希并在用户登录成功时透明升级,所以换更强算法不需要重置所有密码。
要上 Argon2 得先装依赖再把它放到首位(实测未装时 import argon2 直接 ImportError):
1 | $ pip install argon2-cffi |
1 | PASSWORD_HASHERS = [ |
8.2 校验器默认是空的(实测)
不配 AUTH_PASSWORD_VALIDATORS 时,弱密码全部放行:
1 | 123456 通过 |
配上官方推荐的四把尺之后(min_length 我调成 9):
1 | 123456 -> This password is too short. It must contain at least 9 characters. |
两个实测发现值得记住:
- 报错文案一半英文一半中文:
too short那句没有中文翻译,另外两句有。这是 Django 自身的翻译覆盖不全,不是配置问题。 alice1234567这种「用户名 + 数字」的弱口令能过:UserAttributeSimilarityValidator用的是相似度阈值,短用户名加数字未必越过线。真要严格,得自己再写一个自定义 validator。
配置写法(注意类名大小写,老笔记里 UserAttributeSimilarityvalidator、CommonPasswordvalidator 都拼错了):
1 | AUTH_PASSWORD_VALIDATORS = [ |
注册接口用 validate_password 才生效,它不会自动作用在 set_password 上:
1 | from django.contrib.auth.password_validation import validate_password |
密码过期:Django 没有内置
官方没有「密码 N 天必须改」的开关,思路是自己记时间:
1 | class Profile(models.Model): |
登录成功后检查并强制跳转改密页(中间件或 user_logged_in 信号都行)。第三方 django-user-accounts 是备选方案,注意老笔记里的 MIDDLEWARE_CLASSES 早已废弃,现在只有 MIDDLEWARE:
1 | MIDDLEWARE = [ |
连续登录失败要验证码
实测一个基于缓存的计数器(阈值 3):
1 | 第 1 次 -> 失败次数=1 需要验证码=False |
实现要点:
- 计数放共享缓存(Redis),别用进程内存——多 worker 时每个进程各数各的,阈值形同虚设。
- key 至少带「用户名 + 来源 IP」,只按 IP 会被换用户名绕过,只按用户名会被用来锁死别人账号。
- 登录成功要清零,并且验证通过后把验证码答案从存储里删掉(《Django-验证码》里实测过不删的后果:同一个码可以反复试账号密码)。
- 验证码插件的写法见《Django-验证码》篇,这里只讲策略。
限流:把爆破挡在应用之外
Nginx limit_req 实测
配置(登录路径 5 r/m,桶容量 1):
1 | limit_req_zone $binary_remote_addr zone=login:1m rate=5r/m; |
连打 8 次的返回码:
1 | 200 200 503 503 503 503 503 503 |
Nginx 错误日志同时给出判决依据:
1 | limiting requests, excess: 1.999 by zone "login", client: 127.0.0.1 |
两个实测结论:
- 被拒的请求返回的是 503(Nginx 的默认拒绝码),不是 429;想给 429 要自己配
limit_req_status 429;。 - 被拒的请求根本不会到 Django,所以后端日志里看不到它们——排查「用户说点不动」时要看 Nginx 日志而不是应用日志。
应用层的限流
REST 接口的限流用 DRF 自己的 DEFAULT_THROTTLE_RATES(细节在《Django-RESTful-Api之认证和权限》),老笔记里那段配置有个大小写笔误:DEFAULT_THROTTLE_CLASSEs → 正确是 DEFAULT_THROTTLE_RATES 配套的 DEFAULT_THROTTLE_CLASSES。
普通页面想限流,第三方 django-ratelimit 是常见选择(本机未装,属备选):
1 | from ratelimit.decorators import ratelimit |
老笔记里 rate='5/m" 是引号打错的笔误,正确写法两边都用单引号或双引号。
数据安全:加密、脱敏与密钥
10.1 对称加密用 Fernet(实测)
1 | $ pip install cryptography # 实测装的是 50.0.2,只多带 cffi/pycparser,没动 Django |
1 | from cryptography.fernet import Fernet |
注意三件事:
- 密钥本身要单独存放(环境变量/KMS),别和密文进同一个库。
- Fernet 只保证机密性,不防「密文被替换」以外的逻辑篡改,业务上还要带版本号/时间戳。
- Django 没有内置「模型字段自动加密」,官方立场是交给应用层;第三方
django-encrypted-model-fields属备选,升级 Django 时要先看它是否跟进。
日志脱敏
老笔记里那行 asensitive_variables(...) 是漏了 @ 的笔误。正确用法与实测效果:
1 | from django.views.decorators.debug import sensitive_post_parameters, sensitive_variables |
实测把异常交给 ExceptionReporter 生成报告:
1 | 明文口令出现在 traceback = False |
也就是值被藏起来了,但函数名、行号、调用栈照常保留(排错还需要它们)。ExceptionReporter 在 django.views.debug 里,不是 django.core.exceptions(这个坑我踩过一次,直接 ImportError)。
要脱敏的字段清单:用户名、密码、手机号、身份证号、银行卡号、地址、token、验证码答案。
密钥存储原则
- 基础:环境变量或独立配置文件,绝不进代码库;
.gitignore里加.env。 - 读不到就报错,别给弱默认(实测
os.environ.get(name, "dev-only-insecure-key")会静默降级)。 - systemd 用
EnvironmentFile=/etc/djdeploy/env(文件权限 600),不要把密钥写进 unit 文件——systemctl show会把 unit 全文打出来。 - Key Server:HashiCorp Vault、square/keywhiz、pinterest/knox,或云 KMS(本机未部署,属备选)。
- 容器场景在启动时注入环境变量,密钥不落到镜像层。
1 | # 从独立文件读密钥(老笔记里 SECRET__KEY 双下划线是笔误) |

部署架构上的安全设计
- 应用服务器不直接暴露:只有 Nginx 监听公网,gunicorn/uvicorn 只绑
127.0.0.1或 unix socket。 - 数据库、缓存只允许内网地址访问,且和 Web 不在同一安全组段。
- 静态资源走独立域名或 CDN,Cookie 域与之隔离(避免把会话 cookie 带到静态域名上)。
- 内网私有子网 + 跳板机/堡垒机登录,运维端口不对公网开放。
- 多实例时
SECRET_KEY、SESSION_ENGINE必须共享(数据库或 Redis 存 session),否则负载均衡下会反复掉登录。

上线前的安全检查清单
python manage.py check --deploy,把 W009(密钥)这类必须消的消掉。DEBUG=False,ALLOWED_HOSTS精确到域名。- HTTPS 就位、Nginx 强制跳转、
X-Forwarded-Proto由 Nginx 覆盖而不是透传。 - 确认
SECURE_*打开的顺序:先证书 → 再SESSION_COOKIE_SECURE/CSRF_COOKIE_SECURE→ 最后 HSTS 短max-age起步。 CSRF_TRUSTED_ORIGINS覆盖所有实际访问域名(带 scheme)。AUTH_PASSWORD_VALIDATORS四把尺 + 注册接口调用validate_password。- 登录失败计数放共享缓存,阈值 + 验证码 + 成功清零。
- 敏感参数加
sensitive_variables/sensitive_post_parameters。 - 数据库账号最小权限,服务只监听内网/回环。
- 密钥不在仓库里,轮换方案写清楚(换
SECRET_KEY会让所有会话与令牌失效)。
参考
- Django 官方:部署清单:
SECURE_*、Cookie、HSTS 各开关的官方口径。 - Django 官方:CSRF 保护:
Origin/Referer校验规则与CSRF_TRUSTED_ORIGINS。 - Django 官方:XSS 防护:自动转义、
|safe、mark_safe的边界。 - Django 官方:SQL 注入防护:参数化与结构位白名单。
- Django 官方:密码管理:
PASSWORD_HASHERS、AUTH_PASSWORD_VALIDATORS、透明升级。 - Django 官方:管理内容与敏感信息:
sensitive_variables、sensitive_post_parameters。 - ufw 官方文档:默认策略与规则管理。
- Nginx
limit_req模块文档:rate、burst、limit_req_status。 - certbot 官方文档:webroot / standalone 与自动续期(本机未验证)。
- cryptography 官方文档:Fernet 对称加密。
- django-ratelimit(PyPI):应用层限流备选方案。