fix: v0.8.5 安全加固 — CRITICAL/HIGH 漏洞修复

- C1/C2: 移除 settings.py 中硬编码的数据库密码和 SECRET_KEY 默认值
- K8s: DB_PASSWORD/DB_HOST/DB_USER/DJANGO_SECRET_KEY 改为 secretKeyRef
- H1: DEBUG 默认值从 True 改为 False
- H2: 登录接口添加 ScopedRateThrottle (5/min),全局限流 (anon 30/min, user 120/min)
- H4: Django Admin 仅在 DEBUG=True 时注册
- H6: PromptInput innerHTML 使用 DOMPurify 消毒防止 XSS
- H7: ALLOWED_HOSTS 从 "*" 收紧为实际域名
- H9: Nginx 添加安全响应头 + server_tokens off
- M1: 密码策略加强 (min 8 + CommonPassword + NumericPassword)
- M5: Django 生产环境安全头配置
- L1: 登录接口改为 POST-only

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-16 02:08:50 +08:00
parent 4c0605e589
commit d9a12af078
9 changed files with 131 additions and 21 deletions

View File

@ -1,7 +1,8 @@
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.decorators import api_view, permission_classes, throttle_classes
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework_simplejwt.tokens import RefreshToken
from django.contrib.auth import authenticate, get_user_model
from django.utils import timezone
@ -12,6 +13,10 @@ from .serializers import UserSerializer
User = get_user_model()
class LoginRateThrottle(ScopedRateThrottle):
scope = 'login'
@api_view(['POST'])
@permission_classes([AllowAny])
def register_view(request):
@ -22,14 +27,13 @@ def register_view(request):
)
@api_view(['GET', 'POST'])
@api_view(['POST'])
@permission_classes([AllowAny])
@throttle_classes([LoginRateThrottle])
def login_view(request):
"""GET/POST /api/v1/auth/login"""
if request.method == 'GET':
return Response({'message': 'Use POST to login', 'required_fields': ['username', 'password']})
"""POST /api/v1/auth/login"""
username = request.data.get('username', '')
username = request.data.get('username', '').strip()
password = request.data.get('password', '')
# Try authenticate with username first, then email

View File

@ -6,12 +6,13 @@ from datetime import timedelta
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get(
'DJANGO_SECRET_KEY',
'django-insecure-dev-key-change-in-production-airdrama-2026'
)
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', '')
if not SECRET_KEY:
import warnings
warnings.warn('DJANGO_SECRET_KEY not set — using insecure dev key. Do NOT deploy like this.')
SECRET_KEY = 'django-insecure-dev-only-do-not-deploy'
DEBUG = os.environ.get('DJANGO_DEBUG', 'True').lower() in ('true', '1', 'yes')
DEBUG = os.environ.get('DJANGO_DEBUG', 'False').lower() in ('true', '1', 'yes')
ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
@ -81,8 +82,8 @@ elif os.environ.get('USE_MYSQL', 'false').lower() in ('true', '1', 'yes'):
'ENGINE': 'django.db.backends.mysql',
'NAME': os.environ.get('DB_NAME', 'video_auto'),
'USER': os.environ.get('DB_USER', 'ai_video'),
'PASSWORD': os.environ.get('DB_PASSWORD', 'JogNQdtrd3WY8CBCAiYfYEGx'),
'HOST': os.environ.get('DB_HOST', 'rm-7xv1uaw910558p1788o.mysql.rds.aliyuncs.com'),
'PASSWORD': os.environ.get('DB_PASSWORD', ''),
'HOST': os.environ.get('DB_HOST', 'localhost'),
'PORT': os.environ.get('DB_PORT', '3306'),
'OPTIONS': {
'charset': 'utf8mb4',
@ -101,7 +102,9 @@ else:
AUTH_USER_MODEL = 'accounts.User'
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'OPTIONS': {'min_length': 6}},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'OPTIONS': {'min_length': 8}},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
# REST Framework
@ -116,6 +119,15 @@ REST_FRAMEWORK = {
'rest_framework.renderers.JSONRenderer',
),
'EXCEPTION_HANDLER': 'utils.log_center.custom_exception_handler',
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle',
],
'DEFAULT_THROTTLE_RATES': {
'anon': '30/minute',
'user': '120/minute',
'login': '5/minute',
},
}
# JWT settings
@ -149,6 +161,15 @@ STATIC_ROOT = BASE_DIR / 'staticfiles'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# ──────────────────────────────────────────────
# Security headers (production)
# ──────────────────────────────────────────────
if not DEBUG:
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = 'DENY'
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# ──────────────────────────────────────────────
# TOS (Volcano Engine Object Storage)
# ──────────────────────────────────────────────

View File

@ -1,6 +1,7 @@
from django.contrib import admin
from django.urls import path, include
from django.http import JsonResponse
from django.conf import settings
def healthz(request):
@ -9,7 +10,10 @@ def healthz(request):
urlpatterns = [
path('healthz/', healthz),
path('admin/', admin.site.urls),
path('api/v1/auth/', include('apps.accounts.urls')),
path('api/v1/', include('apps.generation.urls')),
]
# Only expose Django admin in DEBUG mode
if settings.DEBUG:
urlpatterns.insert(1, path('admin/', admin.site.urls))

View File

@ -4,6 +4,35 @@
---
## 2026-03-16 — v0.8.5: 安全加固CRITICAL + HIGH 修复)
**状态**: ✅ 已完成 | **验收**: 待线上验证
### 变更内容
1. **C1/C2: 密钥硬编码清除**`settings.py` 移除数据库密码和 SECRET_KEY 默认值,`backend-deployment.yaml` 中 DB_PASSWORD/DB_HOST/DB_USER/DJANGO_SECRET_KEY 改为 K8s Secret 引用
2. **H1: DEBUG 默认改 False** — 防止生产环境遗漏配置时暴露调试信息
3. **H2: 登录限流** — DRF `ScopedRateThrottle` 实现 `login: 5/min`,全局匿名 30/min、认证用户 120/min
4. **H4: Django Admin 限制** — 仅在 `DEBUG=True` 时注册 `/admin/` URL
5. **H6: XSS 防护** — 安装 DOMPurify`PromptInput.tsx``innerHTML` 赋值前进行 HTML 消毒
6. **H7: ALLOWED_HOSTS 收紧** — 从 `"*"` 改为 `video-huoshan-api.airlabs.art,localhost`
7. **H9: Nginx 安全头**`server_tokens off` + X-Frame-Options/X-Content-Type-Options/X-XSS-Protection/Referrer-Policy/Permissions-Policy
8. **M1: 密码策略加强** — 最小 8 位 + 常见密码检测 + 纯数字密码检测
9. **M5: Django 安全头** — 生产环境启用 XSS Filter/Content-Type-Nosniff/X-Frame-Options/SSL Proxy Header
10. **L1: 登录 POST-only** — 移除 GET 方法支持
### 变更文件
| 文件 | 改动 |
|------|------|
| `backend/config/settings.py` | SECRET_KEY/DB 默认值清除、DEBUG 默认 False、密码策略加强、DRF 限流配置、生产安全头 |
| `backend/config/urls.py` | Django Admin 仅 DEBUG 模式注册 |
| `backend/apps/accounts/views.py` | 登录 POST-only + LoginRateThrottle |
| `k8s/backend-deployment.yaml` | DB/SECRET_KEY 改为 secretKeyRef、ALLOWED_HOSTS 收紧 |
| `web/nginx.conf` | server_tokens off + 5 个安全响应头 |
| `web/src/components/PromptInput.tsx` | DOMPurify 消毒 innerHTML |
| `web/package.json` | 新增 dompurify 依赖 |
---
## 2026-03-16 — v0.8.4: 管理员操作审计日志
**状态**: ✅ 已完成 | **验收**: ✅ 通过(本地测试)

View File

@ -26,18 +26,30 @@ spec:
- name: DJANGO_DEBUG
value: "False"
- name: DJANGO_ALLOWED_HOSTS
value: "*"
value: "video-huoshan-api.airlabs.art,localhost"
- name: DJANGO_SECRET_KEY
value: "video-huoshan-prod-secret-key-2026"
valueFrom:
secretKeyRef:
name: video-backend-secrets
key: DJANGO_SECRET_KEY
# Database (Aliyun RDS)
- name: DB_HOST
value: "rm-7xv1uaw910558p1788o.mysql.rds.aliyuncs.com"
valueFrom:
secretKeyRef:
name: video-backend-secrets
key: DB_HOST
- name: DB_NAME
value: "video_auto"
- name: DB_USER
value: "ai_video"
valueFrom:
secretKeyRef:
name: video-backend-secrets
key: DB_USER
- name: DB_PASSWORD
value: "JogNQdtrd3WY8CBCAiYfYEGx"
valueFrom:
secretKeyRef:
name: video-backend-secrets
key: DB_PASSWORD
- name: DB_PORT
value: "3306"
# CORS

View File

@ -1,9 +1,18 @@
server_tokens off;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# API requests proxy to backend service
location /api/ {
proxy_pass http://video-backend:8000;

28
web/package-lock.json generated
View File

@ -10,6 +10,7 @@
"dependencies": {
"@arco-design/web-react": "^2.64.0",
"axios": "^1.13.6",
"dompurify": "^3.3.3",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"react": "^18.3.1",
@ -22,6 +23,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/dompurify": "^3.0.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-router-dom": "^5.3.3",
@ -1628,6 +1630,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1695,6 +1707,13 @@
"@types/react-router": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@ -2224,6 +2243,15 @@
"csstype": "^3.0.2"
}
},
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

@ -12,6 +12,7 @@
"dependencies": {
"@arco-design/web-react": "^2.64.0",
"axios": "^1.13.6",
"dompurify": "^3.3.3",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"react": "^18.3.1",
@ -24,6 +25,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/dompurify": "^3.0.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-router-dom": "^5.3.3",

View File

@ -1,4 +1,5 @@
import { useRef, useEffect, useCallback, useState } from 'react';
import DOMPurify from 'dompurify';
import { useInputBarStore } from '../store/inputBar';
import type { UploadedFile } from '../types';
import styles from './PromptInput.module.css';
@ -35,7 +36,7 @@ export function PromptInput() {
const el = editorRef.current;
if (!el) return;
if (el.innerHTML !== editorHtml) {
el.innerHTML = editorHtml;
el.innerHTML = DOMPurify.sanitize(editorHtml, { ALLOWED_TAGS: ['span', 'br'], ALLOWED_ATTR: ['class', 'contenteditable', 'data-ref-id', 'data-ref-type'] });
// If the HTML is plain text but we have references, rebuild mention spans
// This handles the case where editorHtml comes from backend (plain text only)
if (editorHtml && !editorHtml.includes('data-ref-id') && references.length > 0) {