有两件事让我开始认真对待安全加固。
第一件:有天在搜索引擎里搜我们自己的域名,发现能搜到一个
.env.bak文件——里面有数据库密码。原因是某次调试时我把.env复制成了.env.bak,而它恰好被放在了一个 nginx 不做拦截的目录下。虽然立刻删了,但谁也不知道它被缓存了多久、有没有被下载过。第二件:我们的一个工具功能允许用户上传图片,我校验了扩展名(
.jpg/.png),但没校验内容。有人传了一个内容其实是 HTML 的文件,改名成.jpg传上来——它会被服务器以 HTML 形式解析(取决于 nginx 配置),等于在我们的域名下放了一个可执行的页面。这两件事都不需要高深的技术就能做到,也都不需要高深的技术就能防住。这篇是那之后整理的完整清单,包括配置、密钥、传输、响应头、上传校验、依赖扫描,以及怎么把这些变成 CI 里的自动检查。
TL;DR:最小必做清单——
DEBUG=False+ALLOWED_HOSTS、密钥全部走环境变量、不进 Git、全站 HTTPS + HSTS、SESSION_COOKIE_SECURE/CSRF_COOKIE_SECURE、安全响应头(CSP 最难但值得做)、文件上传必须校验内容类型而不是扩展名、admin 路径改掉 + 登录限流、依赖定期pip-audit扫描。最后把这些做成 CI 步骤(check --deploy+bandit+gitleaks),让机器帮你守住。
目录
- 一、check --deploy 会告诉你什么
- 二、密钥与配置:最容易泄露的一环
- 三、传输与 Cookie
- 四、安全响应头(含 CSP 的渐进式落地)
- 五、注入类:SQL、XSS、CSRF、命令、SSRF
- 六、文件上传:校验内容而不是名字
- 七、认证、会话与后台
- 八、依赖与运维
- 九、把检查变成 CI 步骤
- 十、完整清单(可打印)
- 十一、坑清单
一、check --deploy 会告诉你什么
Django 自带一个部署检查:
python manage.py check --deploy
它会输出一堆警告(W004、W008...),每一条都有编号和文档链接。常见的几条:
| 编号 | 检查项 | 修复 |
|---|---|---|
W004 |
DEBUG 是 True |
生产必须 False |
W008 |
SECURE_HSTS_SECONDS 未设置 |
配 HSTS |
W012 |
SESSION_COOKIE_SECURE 未设 |
True |
W016 |
CSRF_COOKIE_SECURE 未设 |
True |
W018 |
DEBUG_PROPAGATE_EXCEPTIONS |
不要开 |
W020 |
ALLOWED_HOSTS 为空 |
填域名 |
W021 |
SECURE_SSL_REDIRECT |
True(或由 nginx 处理) |
但它检查不到很多东西:密钥是否泄露、上传校验、业务逻辑越权、依赖漏洞。所以它只是第一步,不是全部。
我把它加到了 CI 里(强制通过),并配合下面这些手动检查。
二、密钥与配置:最容易泄露的一环
原则:密钥不进代码库
# 错:硬编码
SECRET_KEY = 'django-insecure-xxxxx'
ALIPAY_APP_ID = '202100...'
WECHAT_SECRET = 'c956...'
# 对:从环境变量读
import os
SECRET_KEY = os.environ['DJANGO_SECRET_KEY']
Django 的 SECRET_KEY 尤其重要:它用于签名 session、密码重置 token、消息签名。泄露意味着攻击者可以伪造 session 和重置链接。
生成一个新的:
from django.core.management.utils import get_random_secret_key
print(get_random_secret_key())
项目里已有的硬编码密钥怎么办:
- 改代码从环境变量读;
- 轮换密钥(不是改代码就够了,旧密钥已经进了 Git 历史,要作废);
- 轮换的代价:所有用户的 session 失效(要重新登录),这个是可以接受的。
.env 的管理
# .env(不进 Git)
DJANGO_SECRET_KEY=xxx
DB_PASSWORD=xxx
ALIPAY_PRIVATE_KEY=xxx
.env
.env.*
!.env.example
.env.example 进 Git(只有键没有值),方便别人知道要配什么。
检查 Git 历史里有没有泄露
# 搜索历史里的密钥
git log -p | grep -i -E 'password|secret|api_key|private_key' | head -20
# 用工具扫(更全)
gitleaks detect --source . --verbose
gitleaks 应该加进 CI。如果发现历史里有密钥,第一反应是轮换,而不是删提交(提交已经推送出去就可能被 clone 过了)。
别把不该暴露的文件放在 web 目录
那次 .env.bak 事故的教训:
# 显式拒绝访问敏感文件
location ~* \.(env|git|svn|bak|old|sql|log|conf)$ {
deny all;
return 404;
}
location ~ /\.git {
deny all;
return 404;
}
更重要的一条:静态目录只放静态文件。任何"临时放一下"的备份、日志、导出文件,都放到 web 根目录之外。
三、传输与 Cookie
HTTPS 相关
Django 侧(如果在反向代理后面,要让 Django 知道真实协议):
SECURE_SSL_REDIRECT = True # 或者由 nginx 做 301
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') # 关键!
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True # 确认所有子域名都支持再开
SECURE_HSTS_PRELOAD = True # 谨慎,见下
SECURE_PROXY_SSL_HEADER 必须配(在 nginx 后面时),否则 Django 认为所有请求都是 HTTP,SECURE_SSL_REDIRECT 会导致无限重定向循环。这个循环很经典:ERR_TOO_MANY_REDIRECTS。
SECURE_HSTS_PRELOAD:加入浏览器预加载列表,几乎不可逆(从列表移除要几个月)。确认长期只用 HTTPS 再加。
Cookie
SESSION_COOKIE_SECURE = True # 只通过 HTTPS 传输
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True # JS 读不到(防 XSS 窃取)
SESSION_COOKIE_SAMESITE = 'Lax' # 防 CSRF
CSRF_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_AGE = 60 * 60 * 24 * 7 # 会话有效期
SESSION_EXPIRE_AT_BROWSER_CLOSE = False
SameSite 的选择:
| 值 | 行为 | 何时用 |
|---|---|---|
Strict |
跨站请求完全不带 cookie | 安全但影响体验(从外链跳进来要重新登录) |
Lax(默认) |
跨站的 GET 导航带,POST 不带 | 推荐 |
None |
都带(必须配合 Secure) | 需要跨站嵌入时 |
四、安全响应头(含 CSP 的渐进式落地)
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_BROWSER_XSS_FILTER = True # 老浏览器,现代浏览器靠 CSP
X_FRAME_OPTIONS = 'DENY' # 不允许被 iframe 嵌入(防点击劫持)
SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin'
或者直接在 nginx 统一加(推荐,这样静态资源也有):
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
CSP:最麻烦但最值得
CSP(Content Security Policy)能挡住大部分 XSS 的实际危害(即使页面里有注入的脚本,没有合法来源也执行不了)。
直接上严格 CSP 会炸掉你的页面(内联脚本、内联样式、第三方 CDN 全被拦)。正确做法是渐进式:
第一步:只报告不拦截
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; report-uri /csp-report" always;
配一个接收报告的视图:
import json
import logging
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
logger = logging.getLogger('csp')
@csrf_exempt
def csp_report(request):
if request.method == 'POST' and request.body:
try:
data = json.loads(request.body)
logger.info('csp violation', extra={
'uri': data.get('csp-report', {}).get('document-uri'),
'blocked': data.get('csp-report', {}).get('blocked-uri'),
'violated': data.get('csp-report', {}).get('violated-directive'),
})
except Exception:
pass
return HttpResponse(status=204)
跑一两周,看日志里被拦的都是什么。你会发现一堆意料之外的东西(jQuery 的内联事件、Google Analytics、字体 CDN、我们的 SSE 连接……)。
第二步:把合法来源加进去
add_header Content-Security-Policy "
default-src 'self';
script-src 'self' 'nonce-{随机}' https://cdn.example.com;
style-src 'self' 'unsafe-inline' https://cdn.example.com;
img-src 'self' data: https:;
font-src 'self' https://cdn.example.com;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
object-src 'none';
base-uri 'self';
" always;
第三步:切到拦截模式(把 Report-Only 去掉)。
关于 nonce:内联脚本用 nonce 而不是 'unsafe-inline'(后者等于放行所有内联脚本,CSP 就白配了)。Django 侧生成 nonce:
# middleware
class CSPNonceMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
import secrets
request.csp_nonce = secrets.token_urlsafe(16)
response = self.get_response(request)
return response
模板里:
<script nonce="{{ request.csp_nonce }}">
// 内联脚本
</script>
nginx 侧要把 nonce 插进 header(用变量)。或者用 django-csp 这个库,它把这些都封装好了(推荐,比手写省事)。
我们的实际经验:从 Report-Only 到正式拦截花了三周(主要是处理第三方脚本和老模板里的内联事件)。但做完之后,XSS 的实际危害降了一个数量级。
五、注入类:SQL、XSS、CSRF、命令、SSRF
SQL 注入
Django ORM 天然防注入(参数化查询)。危险的是手写 SQL:
# 危险:字符串拼接
Model.objects.raw(f"SELECT * FROM t WHERE name = '{user_input}'")
# 安全:参数化
Model.objects.raw("SELECT * FROM t WHERE name = %s", [user_input])
# extra() 也危险,尽量不用
Model.objects.extra(where=[f"name = '{user_input}'"]) # 危险
代码评审时所有 raw() 和 extra() 都要重点看。
XSS
Django 模板默认转义,危险的是:
{{ user_content }} <!-- 安全,自动转义 -->
{{ user_content|safe }} <!-- 危险! -->
{% autoescape off %}...{% endautoescape %} <!-- 危险! -->
需要渲染用户内容(比如富文本)时,用 bleach 净化(项目里已经在用了):
import bleach
clean_html = bleach.clean(
user_html,
tags=['p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'code', 'pre', 'img'],
attributes={'a': ['href', 'title'], 'img': ['src', 'alt']},
strip=True,
)
注意 javascript: 协议:bleach 会处理,但如果你自己写正则清洗,记得检查 href 的值。
我们的博客模型就在 save() 里做了这个净化(前面几篇提到过),是同样的道理——存进去之前净化,输出时也转义,双保险。
CSRF
Django 的 CsrfViewMiddleware 默认开启。要注意的:
- AJAX 请求要带 token:
function getCookie(name) {
const v = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
return v ? v.pop() : '';
}
fetch('/api/xxx/', {
method: 'POST',
headers: {'X-CSRFToken': getCookie('csrftoken')},
body: JSON.stringify(data),
});
@csrf_exempt要谨慎:每个 exempt 的视图都要有别的保护(比如签名验证、或者它本来就是公开的 webhook 且做了签名校验)。- webhook 端点(接收第三方回调)通常要 csrf_exempt,但必须验证签名(支付宝、微信都有签名机制)。
命令注入
# 危险:shell=True + 用户输入
subprocess.run(f"ffmpeg -i {user_path} ...", shell=True)
# 安全:列表形式,不用 shell
subprocess.run(['ffmpeg', '-i', user_path, 'out.mp4'], check=True)
永远用列表形式,永远不要 shell=True 拼接用户输入。 我们的工具站里有几个功能要调 ffmpeg,全是列表形式 + 参数白名单校验。
SSRF
如果功能允许用户提供 URL(下载器、webhook 测试、图片抓取),必须校验目标地址:
import ipaddress
import socket
from urllib.parse import urlparse
BLOCKED = [
ipaddress.ip_network('127.0.0.0/8'),
ipaddress.ip_network('10.0.0.0/8'),
ipaddress.ip_network('172.16.0.0/12'),
ipaddress.ip_network('192.168.0.0/16'),
ipaddress.ip_network('169.254.0.0/16'), # 云元数据服务!
ipaddress.ip_network('::1/128'),
]
def is_safe_url(url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https'):
return False
try:
infos = socket.getaddrinfo(parsed.hostname, None)
except socket.gaierror:
return False
for info in infos:
ip = ipaddress.ip_address(info[4][0])
if any(ip in net for net in BLOCKED):
return False
return True
关键:解析后要逐个 IP 检查(防 DNS rebinding),并且要自己发请求时重新校验(DNS 可能两次解析结果不同)。这个话题项目里另有一篇专门讲下载器的 SSRF,这里不展开。
六、文件上传:校验内容而不是名字
那次 .html 改名成 .jpg 的事故之后,我们的上传校验做成了这样:
import os
import uuid
import magic # python-magic,读文件头判断真实类型
ALLOWED = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'application/pdf': '.pdf',
}
MAX_SIZE = 20 * 1024 * 1024
def validate_upload(uploaded_file):
"""校验上传文件:大小、真实类型。返回 (是否通过, 错误信息)。"""
if uploaded_file.size > MAX_SIZE:
return False, f'文件不能超过 {MAX_SIZE // 1024 // 1024}MB'
# 读前 2048 字节判断真实类型(不能信 Content-Type,更不能信扩展名)
head = uploaded_file.read(2048)
uploaded_file.seek(0) # 一定要复位!
mime = magic.from_buffer(head, mime=True)
if mime not in ALLOWED:
return False, f'不支持的文件类型(检测到 {mime})'
return True, ''
def save_upload(uploaded_file, dest_dir):
"""用服务端生成的名字保存,绝不用用户提供的文件名。"""
ok, msg = validate_upload(uploaded_file)
if not ok:
raise ValidationError(msg)
head = uploaded_file.read(2048)
uploaded_file.seek(0)
mime = magic.from_buffer(head, mime=True)
ext = ALLOWED[mime]
# 文件名完全由服务端生成
filename = f'{uuid.uuid4().hex}{ext}'
path = os.path.join(dest_dir, filename)
with open(path, 'wb+') as f:
for chunk in uploaded_file.chunks():
f.write(chunk)
return filename
四条原则:
- 校验真实内容(
magic.from_buffer读文件头),不信扩展名、不信Content-Type; - 文件名由服务端生成(UUID + 白名单扩展名),绝不用用户提供的名字(防路径穿越、防覆盖、防奇怪字符);
- 限制大小(否则一个 10GB 的文件能把磁盘写满);
- 存储目录不给执行权限。
SVG 要特别处理:SVG 是 XML,可以内嵌 <script>,本质是"图片格式的可执行内容"。如果必须支持 SVG 上传,要么用 DOMPurify 净化,要么转换成 PNG 再存(我们选了后者,因为 SVG 的收益不大)。
七、认证、会话与后台
密码:Django 默认用 PBKDF2,配置里检查一下:
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
'django.contrib.auth.hashers.Argon2PasswordHasher', # 更强,需要装 argon2-cffi
]
登录限流(防暴力破解):
from django_ratelimit.decorators import ratelimit
@ratelimit(key='ip', rate='10/m', method='POST', block=True)
@ratelimit(key='post:username', rate='5/m', method='POST', block=True) # 按账号也限
def login_view(request):
...
后台路径:默认的 /admin/ 是公开的(虽然要登录)。改成不容易猜的路径:
urlpatterns = [
path('manage-9f2a/', admin.site.urls),
...
]
这只是"减少暴露面",不是安全措施(真正的安全靠强密码 + 限流 + 双因子)。但能挡掉大批扫描器。
双因子(2FA):如果后台涉及敏感数据,值得加(django-otp)。我们给管理员账号加了。
密码重置:Django 的 token 是一次性的且有过期时间,不要自己实现(自己写的经常有"token 可重复使用"或者"不过期"的问题)。
八、依赖与运维
定期扫依赖漏洞:
pip install pip-audit
pip-audit -r requirements.txt
或者 safety。我们把它放进了 CI,高危漏洞阻断构建。
镜像漏洞(前面 Docker 那篇讲过):
trivy image viddown:latest
错误信息不要泄露细节:
DEBUG = False
# Django 会自动发邮件给 ADMINS(500 错误),但不会把堆栈显示给用户
LOGGING = {
...
'mail_admins': {'level': 'ERROR', 'class': 'django.utils.log.AdminEmailHandler'},
}
默认错误页面:Django 的 500 页面很简单(好)。要确认没有自定义错误页把异常信息打出去。
日志脱敏:前面监控那篇讲过——日志里不能有密码、token、身份证。
备份加密:备份文件里有全量数据,必须加密且密钥分开保管(前面备份那篇讲过)。
九、把检查变成 CI 步骤
人工检查靠不住(会忘),能自动的都自动化:
# .github/workflows/security.yml
name: security
on: [push, pull_request]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # gitleaks 需要完整历史
- name: 密钥泄露扫描
uses: gitleaks/gitleaks-action@v2
- name: Python 代码安全扫描
run: |
pip install bandit
bandit -r downloader/ tools/ -ll # 只报中危以上
- name: 依赖漏洞扫描
run: |
pip install pip-audit
pip-audit -r requirements.txt
- name: Django 部署检查
env:
DJANGO_SECRET_KEY: ci-only-key
DJANGO_DEBUG: 'False'
run: |
python manage.py check --deploy --fail-level=ERROR
几个工具的说明:
| 工具 | 检查什么 | 误报率 |
|---|---|---|
gitleaks |
Git 历史里的密钥 | 低 |
bandit |
Python 代码安全问题(eval、shell=True、硬编码密码) | 中(需要排除一些) |
pip-audit |
依赖 CVE | 低 |
check --deploy |
Django 配置 | 无 |
bandit 的误报:它会报很多"使用了 assert""使用了 random"之类的低危项。用 -ll 只看中危以上,并在代码里用 # nosec 注释标记确认安全的行。
最重要的不是工具,是"阻断":这些检查跑出来有问题必须让构建失败(或者至少在 PR 上红叉),否则没人看。
十、完整清单(可打印)
【配置】
[ ] DEBUG = False
[ ] ALLOWED_HOSTS 已配置(不含 *)
[ ] SECRET_KEY 从环境变量读,且是随机生成的
[ ] 所有第三方密钥(支付/短信/云 API)从环境变量读
[ ] .env 在 .gitignore 里,仓库里只有 .env.example
[ ] 日志级别合理,不打印敏感信息
【传输】
[ ] 全站 HTTPS(含子域名)
[ ] SECURE_PROXY_SSL_HEADER 已配(反代后)
[ ] HSTS 已配(max-age 从小到大)
[ ] SESSION_COOKIE_SECURE / CSRF_COOKIE_SECURE = True
[ ] SESSION_COOKIE_HTTPONLY = True
[ ] SameSite 已设置
【响应头】
[ ] X-Content-Type-Options: nosniff
[ ] X-Frame-Options / frame-ancestors
[ ] Referrer-Policy
[ ] CSP(先 Report-Only,跑两周再切拦截)
【注入防护】
[ ] 没有 raw() / extra() 拼接用户输入
[ ] 没有 |safe 渲染用户内容(或用 bleach 净化)
[ ] 没有 shell=True 拼接用户输入
[ ] 用户提供的 URL 有 SSRF 校验(含 DNS rebinding)
【上传】
[ ] 校验文件真实类型(magic bytes),不只信扩展名
[ ] 文件名由服务端生成
[ ] 限制大小
[ ] 存储目录无执行权限
[ ] SVG 单独处理(或转 PNG)
【认证与后台】
[ ] 后台路径非默认
[ ] 登录接口有限流(IP + 账号)
[ ] 密码哈希算法合理(PBKDF2 / Argon2)
[ ] 敏感操作有二次确认/双因子
【依赖与运维】
[ ] pip-audit 无高危漏洞
[ ] 镜像 trivy 扫描通过
[ ] 备份加密且密钥分离
[ ] 服务器/中间件及时更新
[ ] 有安全事件的响应流程(谁做什么)
【CI】
[ ] gitleaks
[ ] bandit
[ ] pip-audit
[ ] check --deploy
十一、坑清单
DEBUG=True上线 → 出错页面暴露完整代码、SQL、环境变量。最严重的一条。- 密钥硬编码进 Git → 泄露后要轮换,不是删掉就行(历史还在)。
- 备份文件(.env.bak / .sql / .zip)放在 web 目录 → 被搜索引擎收录或直接下载。
- 反代后没配
SECURE_PROXY_SSL_HEADER+ 开了 SSL redirect → 无限重定向循环。 - HSTS 一上来就一年 → HTTPS 出问题时用户彻底打不开。
includeSubDomains太早加 → 没上 HTTPS 的子域名全挂。- 上传只校验扩展名 → 内容可以是任何东西(HTML、PHP、脚本)。
- 用用户提供的文件名保存 → 路径穿越、覆盖、奇怪字符。
raw()里字符串拼接 SQL → SQL 注入。|safe渲染用户内容 → 存储型 XSS。subprocess用shell=True→ 命令注入。- 用户提供的 URL 不做校验就请求 → SSRF(能打到内网、云元数据服务)。
@csrf_exempt的 webhook 不验签 → 任何人可以伪造回调。- CSP 直接上严格模式 → 页面全炸,然后被迫加上
'unsafe-inline'(等于白配)。渐进式。 - 安全工具跑出来不阻断 → 没人看结果,等于没有。
- 只做一次安全检查 → 新功能不断加入。要做成 CI + 每季度人工复查。
最后说说我对"安全加固"这件事的态度转变。
以前我觉得安全是"安全工程师的事",我们做业务的只要功能正常就行。那两次事故之后我意识到:绝大部分实际发生的安全问题,都是"基础配置没做对",而不是"高深漏洞"。
.env.bak 被搜索引擎收录——不是黑客技术,是我把文件放错了地方。
上传 .html 改名成 .jpg——不是绕过 WAF,是我校验错了东西。
DEBUG=True 上线——不是被入侵,是配置忘了改。
这些问题的共同点是:它们都能被一份清单挡住。所以这份清单的价值不在于技术含量,而在于"每次上线都过一遍"这个动作本身。
我现在的做法是:清单贴在仓库的 docs/security-checklist.md,每个含"对外暴露面变化"的 PR(新接口、新上传功能、新配置)都要在描述里勾选相关项。成本几分钟,但那两次事故之后再没出过同类问题。
还有一条:安全事件要有响应流程。知道出事之后第一步做什么(轮换密钥?下线功能?通知用户?),比"到时候再说"强得多。我们的流程写在同一个文档里,包括"谁有权限做什么决定"——这个在真出事的时候能省掉大量混乱的沟通时间。