Django 从入门到精通:知行博客实战(6.1) · 第 26 / 36 节

安全配置与原生 CSP

吴秀峰 3 次阅读 0 条评论 ↓

学习目标

分清 CSRF、XSS、权限和 HTTPS 的职责,为博客启用 Django 原生 CSP,并验证响应头和现有页面是否仍正常。

为什么安全要从多层共同实现

自动转义减少 HTML 注入,CSRF 检查阻止借用会话的跨站写入,ORM 参数化避免普通参数注入,作者过滤防止越权,HTTPS 保护传输。没有某一项设置能替代其余检查。特别是给视图加 csrf_exempt 并不能修复真正的跨域或 Cookie 配置问题。

Django 6.0 起提供原生 CSP 中间件。策略限制脚本、样式、图片等资源来源,是输出转义之外的额外防线。本项目没有第三方脚本,使用 self 和逐响应 nonce 支持必要的内置后台资源,禁止插件对象和嵌入框架,不开启 unsafe-inline 作为默认逃生口。

具体怎么做

  1. 更新设置加入 ContentSecurityPolicyMiddleware 和 csp 上下文处理器。
  2. 设置 SECURE_CSP,使用 CSP 常量避免引号拼写错误。
  3. 加入响应头测试。访问首页、登录、编辑、Admin,观察控制台是否有被拦截的必要资源。
  4. 如果你额外接入编辑器或验证码,先在隔离环境用 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