安全配置与原生 CSP
学习目标
分清 CSRF、XSS、权限和 HTTPS 的职责,为博客启用 Django 原生 CSP,并验证响应头和现有页面是否仍正常。
为什么安全要从多层共同实现
自动转义减少 HTML 注入,CSRF 检查阻止借用会话的跨站写入,ORM 参数化避免普通参数注入,作者过滤防止越权,HTTPS 保护传输。没有某一项设置能替代其余检查。特别是给视图加 csrf_exempt 并不能修复真正的跨域或 Cookie 配置问题。
Django 6.0 起提供原生 CSP 中间件。策略限制脚本、样式、图片等资源来源,是输出转义之外的额外防线。本项目没有第三方脚本,使用 self 和逐响应 nonce 支持必要的内置后台资源,禁止插件对象和嵌入框架,不开启 unsafe-inline 作为默认逃生口。
具体怎么做
- 更新设置加入 ContentSecurityPolicyMiddleware 和 csp 上下文处理器。
- 设置 SECURE_CSP,使用 CSP 常量避免引号拼写错误。
- 加入响应头测试。访问首页、登录、编辑、Admin,观察控制台是否有被拦截的必要资源。
- 如果你额外接入编辑器或验证码,先在隔离环境用 SECURE_CSP_REPORT_ONLY 观察,再根据真实资源逐项配置。Report-Only 不阻止攻击,也不会自动建立收集报告的服务器。
本章新增或替换的完整文件
以下路径均相对于练习项目根目录。替换同名文件的全部内容;未列出的文件保持上一章版本。
blog/test_security.py
from django.test import TestCase
class SecurityHeaderTests(TestCase):
def test_policy_and_frame_headers(self):
response = self.client.get("/")
self.assertIn("default-src 'self'", response["Content-Security-Policy"])
self.assertIn("object-src 'none'", response["Content-Security-Policy"])
self.assertEqual(response["X-Frame-Options"], "DENY")
self.assertEqual(response["X-Content-Type-Options"], "nosniff")
config/settings.py
import os
from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = os.environ.get("DJANGO_DEBUG", "1") == "1"
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "development-only-never-use-this-key-in-production")
if not DEBUG and (SECRET_KEY.startswith("development-") or len(SECRET_KEY) < 50):
raise ImproperlyConfigured("生产环境必须设置至少 50 字符的独立 DJANGO_SECRET_KEY")
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,[::1]").split(",")
INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles",
"accounts.apps.AccountsConfig", "blog.apps.BlogConfig",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.middleware.csp.ContentSecurityPolicyMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
TEMPLATES = [{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"], "APP_DIRS": True,
"OPTIONS": {"context_processors": [
"django.template.context_processors.request",
"django.template.context_processors.csp",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]},
}]
WSGI_APPLICATION = "config.wsgi.application"
ASGI_APPLICATION = "config.asgi.application"
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3"}}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "zh-hans"
TIME_ZONE = "Asia/Shanghai"
USE_I18N = True
USE_TZ = True
STATIC_URL = "/static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
STATIC_ROOT = BASE_DIR / "staticfiles"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
MAILERS = {"default": {"BACKEND": "django.core.mail.backends.console.EmailBackend"}}
AUTH_USER_MODEL = "accounts.User"
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "blog:home"
LOGOUT_REDIRECT_URL = "blog:home"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
DATA_UPLOAD_MAX_MEMORY_SIZE = 3 * 1024 * 1024
FILE_UPLOAD_MAX_MEMORY_SIZE = 2 * 1024 * 1024
# SMTP 可选;没有配置时只打印到开发终端。
if os.environ.get("EMAIL_HOST"):
MAILERS = {"default": {
"BACKEND": "django.core.mail.backends.smtp.EmailBackend",
"OPTIONS": {
"host": os.environ["EMAIL_HOST"],
"port": int(os.environ.get("EMAIL_PORT", "587")),
"username": os.environ.get("EMAIL_HOST_USER", ""),
"password": os.environ.get("EMAIL_HOST_PASSWORD", ""),
"use_tls": True,
"timeout": 10,
},
}}
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "noreply@example.invalid")
PASSWORD_RESET_TIMEOUT = 3600
# 入门采用进程内缓存;多进程间不是共享缓存。
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache", "LOCATION": "zhixing"}}
LOGGING = {
"version": 1, "disable_existing_loggers": False,
"formatters": {"standard": {"format": "{asctime} {levelname} {name} {message}", "style": "{"}},
"handlers": {"console": {"class": "logging.StreamHandler", "formatter": "standard"}},
"root": {"handlers": ["console"], "level": "INFO"},
}
from django.utils.csp import CSP
SECURE_CSP = {
"default-src": [CSP.SELF], "script-src": [CSP.SELF, CSP.NONCE],
"style-src": [CSP.SELF, CSP.NONCE], "img-src": [CSP.SELF, "data:"],
"object-src": [CSP.NONE], "base-uri": [CSP.SELF], "frame-ancestors": [CSP.NONE],
"form-action": [CSP.SELF],
}
SESSION_COOKIE_HTTPONLY = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"
如何确认成功
python manage.py test blog.test_security --settings=config.test_settings
curl -I http://127.0.0.1:8000/
安全测试通过,响应中有 Content-Security-Policy、X-Frame-Options 和 nosniff。页面 CSS、表单和后台仍可正常使用。不要给 HTML 页面做跨请求完整缓存后复用 nonce;nonce 需要与本次响应策略相匹配。
练习:列出一次文章编辑请求经过的检查:登录、CSRF、作者归属、表单校验、数据库约束、转义输出。选择其中一项去掉,说明会出现什么问题,然后保持正确实现。
生产前仍需完成
第 32 章设置 HTTPS 跳转、安全 Cookie 和代理头。秘密信息通过环境注入并限制文件权限,不提交到仓库。媒体按公开内容处理,不能执行脚本;登录和重置入口需要限流。依赖要持续更新安全补丁,但必须先运行回归测试,不直接在生产机器上无版本地升级。
常见问题
403 一定是 CSRF 吗? 也可能是权限,先看日志错误类别。CSP 能清洗正文吗? 不能,仍须转义或严格清洗。为什么不是所有图片域名都允许? 限制来源可减少不必要的外部加载和跟踪,确有需求再加入明确域名。
官方参考
登录后可保存阅读位置、标记章节完成,并在其他设备继续学习。
登录并保存学习进度 →联系站长
微信:VIPdjango。技术问题建议先在 社区讨论 中交流,方便更多人参与和查阅。
章节讨论 0