diff --git a/.env.production.example b/.env.production.example index f152022..e382c58 100644 --- a/.env.production.example +++ b/.env.production.example @@ -17,6 +17,8 @@ API_ANON_RATE=120/minute API_USER_RATE=600/minute CALCULATOR_RATE=30/minute SECURE_HSTS_SECONDS=31536000 +# Set true only when the HTTPS reverse proxy emits a non-zero HSTS max-age. +DJANGO_HSTS_AT_EDGE=false # 仅供部署脚本直接访问 127.0.0.1:8000 时设置 Host 头。 DEPLOY_HEALTH_HOST=math.example.com diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index a8506dc..5df9a37 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -100,6 +100,7 @@ jobs: chmod +x scripts/deploy_production.sh + DJANGO_HSTS_AT_EDGE=true \ PROJECT_DIR="$PROJECT" \ PYTHON_BIN="$PYTHON_BIN" \ PREVIOUS_REVISION="$PREVIOUS_REVISION" \ diff --git a/backend/common/test_deployment_security.py b/backend/common/test_deployment_security.py new file mode 100644 index 0000000..76e159f --- /dev/null +++ b/backend/common/test_deployment_security.py @@ -0,0 +1,16 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def test_edge_hsts_ownership_is_explicit_and_verified(): + settings = (ROOT / "backend/config/settings.py").read_text(encoding="utf-8") + deploy = (ROOT / "scripts/deploy_production.sh").read_text(encoding="utf-8") + workflow = (ROOT / ".gitea/workflows/deploy.yml").read_text(encoding="utf-8") + example = (ROOT / ".env.production.example").read_text(encoding="utf-8") + + assert 'os.getenv("DJANGO_HSTS_AT_EDGE", "false")' in settings + assert '["security.W004"]' in settings + assert "DJANGO_HSTS_AT_EDGE=true" in workflow + assert "strict-transport-security:.*max-age=[1-9][0-9]*" in deploy + assert "DJANGO_HSTS_AT_EDGE=false" in example diff --git a/backend/config/settings.py b/backend/config/settings.py index ce139b0..c3472d5 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -189,6 +189,12 @@ SECURE_HSTS_SECONDS = int( ) SECURE_HSTS_INCLUDE_SUBDOMAINS = USE_HTTPS SECURE_HSTS_PRELOAD = USE_HTTPS +HSTS_AT_EDGE = os.getenv("DJANGO_HSTS_AT_EDGE", "false").lower() == "true" +SILENCED_SYSTEM_CHECKS = ( + ["security.W004"] + if USE_HTTPS and HSTS_AT_EDGE and SECURE_HSTS_SECONDS == 0 + else [] +) LOGGING = { "version": 1, diff --git a/scripts/deploy_production.sh b/scripts/deploy_production.sh index 6380b82..0d2f19b 100755 --- a/scripts/deploy_production.sh +++ b/scripts/deploy_production.sh @@ -234,6 +234,16 @@ if [ -d "$PROJECT_DIR/backend/staticfiles" ]; then fi cd "$PROJECT_DIR/backend" if [ "${DJANGO_USE_HTTPS:-true}" = "true" ]; then + if [ "${DJANGO_HSTS_AT_EDGE:-false}" = "true" ]; then + curl --fail --silent --show-error --head \ + --noproxy '*' \ + --resolve "${HEALTH_HOST}:443:127.0.0.1" \ + --max-time 10 \ + "https://${HEALTH_HOST}/" | + tr -d '\r' | + grep -Eiq '^strict-transport-security:.*max-age=[1-9][0-9]*' + log "边缘代理 HSTS 已验证" + fi "$VENV_DIR/bin/python" manage.py check --deploy --fail-level WARNING --database default else log "临时 HTTP 模式:部署安全检查只阻断 ERROR;绑定域名后应启用 HTTPS"