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 自己的转义与参数化。
  • 系统防火墙只决定端口可达性,它拦不住一个合法端口上的恶意请求。

WAF防火墙

系统防火墙:ufw 实测

默认策略是「拒入放入」,不是「允许所有」

老笔记写的是「开启 ufw 后默认允许所有连接通讯」,这句反了。实测(Ubuntu 26.04,ufw 当前未启用):

1
$ sudo ufw status verbose
1
2
Status: inactive
Default: deny (incoming), allow (outgoing), disabled (routed)

也就是说一 enable 就是「默认拒绝所有入站」,所以开之前必须先放行 SSH,否则第一个把自己关在外面。

ufw

2.2 可以先写规则、后启用(实测安全做法)

ufw 未启用时加规则不会影响任何流量,可以用 show added 先审一遍再 enable:

1
2
3
$ sudo ufw allow 80/tcp
$ sudo ufw deny from 203.0.113.9
$ sudo ufw show added
1
2
3
4
5
6
Added user rules (see 'ufw status' for running firewall):
ufw allow 8080/tcp
ufw allow from 192.168.1.50 to any port 3306
ufw limit 22/tcp
ufw allow 80/tcp
ufw deny from 203.0.113.9

limit 22/tcp 是防爆破 SSH 的推荐写法(自带连接频率限制)。审完再:

1
2
3
$ sudo ufw enable          # 会提示可能中断现有连接,输入 y 确认
$ sudo ufw status numbered # 每条策略都有序号,删除时用序号最稳
$ sudo ufw delete 3

数据库端口对外暴露才是真问题

实测本机 MySQL/Redis/PostgreSQL/Mongo 的监听地址(ss -ltn):

1
2
3
4
5
127.0.0.1:6379    # Redis 只回环
127.0.0.1:27017 # Mongo 只回环
127.0.0.53:53 # systemd-resolved
0.0.0.0:5600 # PostgreSQL(WSL 侧端口映射需要)
*:3306 # MySQL 全网卡 —— 生产必须收紧

优先级应该是:先让服务只监听 127.0.0.1,再谈防火墙规则。同机的 Django 与 MySQL 之间根本不需要公网可达。

云上的安全组(阿里云/AWS 控制台配置入方向规则)属于外部平台操作,未在本机验证,思路一致:只开 80/443(和必要的运维端口),数据库端口一律不对公网开放。

HTTPS:证书、跳转与 HSTS

本机先用自签证书把链路跑通

真证书要域名和公网验证,本机做不了;但 TLS 本身可以真跑。用 openssl 签一张 30 天的:

1
2
3
4
5
6
7
8
$ openssl req -x509 -newkey rsa:2048 -nodes \
-keyout /root/probe-tls/key.pem -out /root/probe-tls/cert.pem \
-days 30 -subj "/CN=probe.local"

$ openssl x509 -in cert.pem -noout -subject -dates
subject=CN=probe.local
notBefore=Nov 4 22:16:10 2026 GMT
notAfter=Dec 4 22:16:10 2026 GMT

Nginx 侧起一个 TLS server,并把 X-Forwarded-Proto 覆盖给后端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
server {
listen 127.0.0.1:8443 ssl;
server_name probe.local;

ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;

location / {
proxy_pass http://127.0.0.1:8021;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

实测结果:

1
2
3
4
https -> 200  ssl_verify_result=18(18=自签,0=受信)
HTTP/1.1 200 OK
Strict-Transport-Security: max-age=31536000
X-Request-Secure: True # 后端确实知道自己是 https

ssl_verify_result 不是 0 很正常,浏览器同样会报证书不受信;换成 Let’s Encrypt 的证书就变 0。

3.2 SECURE_SSL_REDIRECT:301 是后端给的

后端单独起一个开了跳转的 gunicorn(8021),再用明文请求打它:

1
2
http 直连 8021(SECURE_SSL_REDIRECT=True) -> 301
http 经 8088(后端未开跳转) -> 200

生产上通常让 Nginx 做跳转(省一次后端往返),Django 这个开关留给「后端会被直连」的场景兜底。

HSTS 只在 HTTPS 响应里出现

这是最容易配错的一条,实测同一个视图两种请求:

1
2
http  请求 HSTS = (没有)
https 请求 HSTS = max-age=31536000; includeSubDomains; preload

配置:

1
2
3
SECURE_HSTS_SECONDS = 31536000          # 一年起步
SECURE_HSTS_INCLUDE_SUBDOMAINS = True # 所有子域也强制 https
SECURE_HSTS_PRELOAD = True # 允许进浏览器预加载列表

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
2
3
4
location /.well-known/acme-challenge/ {
default_type "text/plain";
root /data/www/example;
}
1
2
3
$ nginx -s reload
$ certbot certonly --webroot -w /data/www/example \
-d example.com -d www.example.com --email admin@example.com

证书目录 /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
2
3
$ certbot renew --dry-run          # 演练续期,不改真证书
$ systemctl list-timers # snap/apt 装的 certbot 会带一个 timer
$ cat /etc/cron.d/certbot # 老版本是 cron 任务

Debian/Ubuntu 上官方推荐用 snap 装:

1
2
3
4
$ sudo apt update && sudo apt install snapd
$ sudo snap install core; sudo snap refresh core
$ sudo snap install --classic certbot
$ sudo ln -s /snap/bin/certbot /usr/bin/certbot

先看清 Django 给了什么

实测 proj.settings.prod 里的值(未显式配置时):

1
2
3
4
5
6
7
CSRF_COOKIE_DOMAIN              = None
CSRF_COOKIE_AGE = 31449600 # 约 1 年
SESSION_COOKIE_AGE = 1209600 # 2 周
SESSION_EXPIRE_AT_BROWSER_CLOSE = False
SESSION_SAVE_EVERY_REQUEST = False
CSRF_COOKIE_HTTPONLY = False
SECURE_HSTS_SECONDS = 0

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
2
3
http  GET /form/ -> 200 cookie: 一个都没有
https GET /form/ -> 200 cookie: ['csrftoken']
csrftoken secure=True httponly=False samesite='Lax'

也就是说在还没上 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
2
CSRF_TRUSTED_ORIGINS = ['https://probe.local']
带 trusted origins 的本域 POST -> 200

三个细节:必须写全 scheme://host(可以带端口);只写 probe.local 不生效;用 env_list 从环境变量给,省得每个环境改代码。

模板与 AJAX 两种带法

1
2
3
4
<form method="post" action="/form/">
{% csrf_token %}
<input name="name" value="demo">
</form>
1
2
3
4
5
6
7
8
9
10
// AJAX:从 cookie 读出来放进请求头
function postJSON(url, data) {
const token = document.cookie.match(/(?:^|;\s*)csrftoken=([^;]+)/)[1];
return fetch(url, {
method: "POST",
headers: {"X-CSRFToken": token, "Content-Type": "application/json"},
body: JSON.stringify(data),
});

}

@csrf_exempt 是给「确实不需要 session 的接口」用的逃生口,别拿它当解决 403 的常规手段——403 通常说明 Origin/域名配置错了。

XSS:转义是默认开的,问题都在「越狱」

同一段恶意载荷 <img src=x onerror="alert(1)">,五种写法的实测输出:

1
2
3
4
5
默认自动转义  : <p>&lt;img src=x onerror=&quot;alert(1)&quot;&gt;</p>
{{ p|safe }} : <p><img src=x onerror="alert(1)"></p>
autoescape off: <p><img src=x onerror="alert(1)"></p>
format_html : <b>&lt;img src=x onerror=&quot;alert(1)&quot;&gt;</b>
escape() : &lt;img src=x onerror=&quot;alert(1)&quot;&gt;

也就是说 Django 模板默认是安全的,被 XSS 打的三条路都是主动关掉了转义:

  1. |safe 过滤器。
  2. {% autoescape off %} 或 mark_safe() 包了用户输入。
  3. 富文本字段本身要允许 HTML,却直接塞进模板——这种必须过白名单清洗(Django 不提供内置清洗,官方建议用第三方,如 bleach/django-bleach;本机未装,属备选)。

响应头侧实测(SecurityMiddleware 默认输出):

1
2
3
4
5
X-Content-Type-Options: nosniff

X-Frame-Options: DENY # 改成 SAMEORIGIN 后实测值随之变
Cross-Origin-Opener-Policy: same-origin
Referrer-Policy: same-origin # 配 no-referrer 后实测变 no-referrer

CSP(Content-Security-Policy)Django 没有内置开关,要靠 Nginx add_header Content-Security-Policy ... 或第三方中间件;这是防 XSS 的最后一道兜底,值得上。

七、SQL 注入:ORM 帮你挡了,raw 没帮你挡

同一条 payload ' OR '1'='1,两种写法的结果(实测):

1
2
3
参数化      -> 命中 0 行(预期 0)
字符串拼接 -> 命中 2 行(>0 就说明注入改变了语义)
ORM filter(name__contains=payload) -> 0 行,不受影响

对应代码:

1
2
3
4
5
6
7
8
9
10
11
from django.db import connection

name = request.GET.get("name", "x")

# 正确:占位符交给驱动,payload 只会变成一个普通字符串值
with connection.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM core_deployitem WHERE name = %s", [name])

# 错误:字符串格式化把 payload 拼进了 SQL 结构
with connection.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM core_deployitem WHERE name = '%s'" % name)

几个容易忽略的点:

  • 参数化用 %s 占位符 + 第二个参数列表,不要用 f-string 或 % 自己拼。
  • 列名、表名、排序方向这类结构位置不能参数化,必须白名单校验:
1
2
3
4
5
6
7
ALLOWED_SORT = {"name", "hits", "created_at"}

def order_by_or_default(field):

if field not in ALLOWED_SORT:
raise ValueError("非法排序字段: %r" % field)
return field
  • Model.objects.raw()、QuerySet.extra()、connection.cursor().executemany() 同样要传参数,别拼字符串。
  • __in 传字符串会被当成逗号分隔的序列,属于逻辑 bug 而不是注入,但排查时最容易混淆。
  • 数据库账号本身也要最小权限(不给 DROP、不给 FILE),这样即使应用层被绕过,损失也可控。

密码:哈希、校验器、过期与登录失败

默认哈希算法实测

1
2
3
算法 = pbkdf2_sha256 | 迭代 = 1000000 | 盐 = oZkAWZLFwrAGNCiMvNUUrB | 校验 = True
HASHERS 顺序 = ['PBKDF2PasswordHasher', 'PBKDF2SHA1PasswordHasher',
'Argon2PasswordHasher', 'BCryptSHA256PasswordHasher', 'ScryptPasswordHasher']

哈希串是 算法$迭代$盐$摘要 四段,Django 会按 PASSWORD_HASHERS 顺序识别旧哈希并在用户登录成功时透明升级,所以换更强算法不需要重置所有密码。

要上 Argon2 得先装依赖再把它放到首位(实测未装时 import argon2 直接 ImportError):

1
$ pip install argon2-cffi
settings.py
1
2
3
4
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
]

8.2 校验器默认是空的(实测)

不配 AUTH_PASSWORD_VALIDATORS 时,弱密码全部放行:

1
2
3
4
123456         通过
abcdefgh 通过
123456789012 通过
alice123 通过

配上官方推荐的四把尺之后(min_length 我调成 9):

1
2
3
4
5
123456                       -> This password is too short. It must contain at least 9 characters.
|| 这个密码太常见了。 || 密码只包含数字。
alice1234567 通过
password123 -> 这个密码太常见了。
correct horse battery staple 通过

两个实测发现值得记住:

  • 报错文案一半英文一半中文:too short 那句没有中文翻译,另外两句有。这是 Django 自身的翻译覆盖不全,不是配置问题。
  • alice1234567 这种「用户名 + 数字」的弱口令能过:UserAttributeSimilarityValidator 用的是相似度阈值,短用户名加数字未必越过线。真要严格,得自己再写一个自定义 validator。

配置写法(注意类名大小写,老笔记里 UserAttributeSimilarityvalidator、CommonPasswordvalidator 都拼错了):

settings.py
1
2
3
4
5
6
7
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
"OPTIONS": {"min_length": 9}},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]

注册接口用 validate_password 才生效,它不会自动作用在 set_password 上:

1
2
3
4
5
6
7
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError

try:
validate_password(raw_password, user)
except ValidationError as exc:
form.add_error("password", exc.messages)

密码过期:Django 没有内置

官方没有「密码 N 天必须改」的开关,思路是自己记时间:

models.py
1
2
3
class Profile(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
password_reset_at = models.DateTimeField("最后改密时间", auto_now_add=True)

登录成功后检查并强制跳转改密页(中间件或 user_logged_in 信号都行)。第三方 django-user-accounts 是备选方案,注意老笔记里的 MIDDLEWARE_CLASSES 早已废弃,现在只有 MIDDLEWARE:

settings.py
1
2
3
4
5
6
7
MIDDLEWARE = [
# ...
"account.middleware.ExpiredPasswordMiddleware",
]

ACCOUNT_PASSWORD_USE_HISTORY = True
ACCOUNT_PASSWORD_EXPIRY = 60 * 60 * 24 * 5 # 5 天,单位秒

连续登录失败要验证码

实测一个基于缓存的计数器(阈值 3):

1
2
3
4
5
6
第 1 次 -> 失败次数=1 需要验证码=False
第 2 次 -> 失败次数=2 需要验证码=False
第 3 次 -> 失败次数=3 需要验证码=True

第 4 次 -> 失败次数=4 需要验证码=True
第 5 次 -> 失败次数=5 需要验证码=True

实现要点:

  • 计数放共享缓存(Redis),别用进程内存——多 worker 时每个进程各数各的,阈值形同虚设。
  • key 至少带「用户名 + 来源 IP」,只按 IP 会被换用户名绕过,只按用户名会被用来锁死别人账号。
  • 登录成功要清零,并且验证通过后把验证码答案从存储里删掉(《Django-验证码》里实测过不删的后果:同一个码可以反复试账号密码)。
  • 验证码插件的写法见《Django-验证码》篇,这里只讲策略。

限流:把爆破挡在应用之外

Nginx limit_req 实测

配置(登录路径 5 r/m,桶容量 1):

1
2
3
4
5
6
7
8
9
limit_req_zone $binary_remote_addr zone=login:1m rate=5r/m;

server {
listen 127.0.0.1:8088;
location /login-fail/ {
limit_req zone=login burst=1 nodelay;
proxy_pass http://127.0.0.1:8022;
}
}

连打 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 是常见选择(本机未装,属备选):

views.py
1
2
3
4
5
6
from ratelimit.decorators import ratelimit


@ratelimit(key="ip", rate="5/m", method="POST")
def login_view(request):
...

老笔记里 rate='5/m" 是引号打错的笔误,正确写法两边都用单引号或双引号。

数据安全:加密、脱敏与密钥

10.1 对称加密用 Fernet(实测)

1
$ pip install cryptography      # 实测装的是 50.0.2,只多带 cffi/pycparser,没动 Django
1
2
3
4
5
6
7
from cryptography.fernet import Fernet

key = Fernet.generate_key() # 44 字符的 base64,另存到密钥管理里
f = Fernet(key)
token = f.encrypt(b"welcome to django")
print(token)
print(f.decrypt(token))

注意三件事:

  • 密钥本身要单独存放(环境变量/KMS),别和密文进同一个库。
  • Fernet 只保证机密性,不防「密文被替换」以外的逻辑篡改,业务上还要带版本号/时间戳。
  • Django 没有内置「模型字段自动加密」,官方立场是交给应用层;第三方 django-encrypted-model-fields 属备选,升级 Django 时要先看它是否跟进。

日志脱敏

老笔记里那行 asensitive_variables(...) 是漏了 @ 的笔误。正确用法与实测效果:

views.py
1
2
3
4
5
6
7
8
9
10
11
from django.views.decorators.debug import sensitive_post_parameters, sensitive_variables


@sensitive_variables("pw", "cc")
def process_info(pw, cc):
raise ValueError("boom")


@sensitive_post_parameters("password")
def login_view(request):
...

实测把异常交给 ExceptionReporter 生成报告:

1
2
3
明文口令出现在 traceback = False
卡号出现在 traceback = False
函数名 leak 在 = True

也就是值被藏起来了,但函数名、行号、调用栈照常保留(排错还需要它们)。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(本机未部署,属备选)。
  • 容器场景在启动时注入环境变量,密钥不落到镜像层。
settings.py
1
2
3
# 从独立文件读密钥(老笔记里 SECRET__KEY 双下划线是笔误)
with open("/etc/secret_key.txt") as f:
SECRET_KEY = f.read().strip()

KeyServer

部署架构上的安全设计

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

架构安全

上线前的安全检查清单

  1. python manage.py check --deploy,把 W009(密钥)这类必须消的消掉。
  2. DEBUG=False,ALLOWED_HOSTS 精确到域名。
  3. HTTPS 就位、Nginx 强制跳转、X-Forwarded-Proto 由 Nginx 覆盖而不是透传。
  4. 确认 SECURE_* 打开的顺序:先证书 → 再 SESSION_COOKIE_SECURE/CSRF_COOKIE_SECURE → 最后 HSTS 短 max-age 起步。
  5. CSRF_TRUSTED_ORIGINS 覆盖所有实际访问域名(带 scheme)。
  6. AUTH_PASSWORD_VALIDATORS 四把尺 + 注册接口调用 validate_password。
  7. 登录失败计数放共享缓存,阈值 + 验证码 + 成功清零。
  8. 敏感参数加 sensitive_variables / sensitive_post_parameters。
  9. 数据库账号最小权限,服务只监听内网/回环。
  10. 密钥不在仓库里,轮换方案写清楚(换 SECRET_KEY 会让所有会话与令牌失效)。

参考