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

View File

@ -6,12 +6,13 @@ from datetime import timedelta
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get( SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', '')
'DJANGO_SECRET_KEY', if not SECRET_KEY:
'django-insecure-dev-key-change-in-production-airdrama-2026' 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(',') 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', 'ENGINE': 'django.db.backends.mysql',
'NAME': os.environ.get('DB_NAME', 'video_auto'), 'NAME': os.environ.get('DB_NAME', 'video_auto'),
'USER': os.environ.get('DB_USER', 'ai_video'), 'USER': os.environ.get('DB_USER', 'ai_video'),
'PASSWORD': os.environ.get('DB_PASSWORD', 'JogNQdtrd3WY8CBCAiYfYEGx'), 'PASSWORD': os.environ.get('DB_PASSWORD', ''),
'HOST': os.environ.get('DB_HOST', 'rm-7xv1uaw910558p1788o.mysql.rds.aliyuncs.com'), 'HOST': os.environ.get('DB_HOST', 'localhost'),
'PORT': os.environ.get('DB_PORT', '3306'), 'PORT': os.environ.get('DB_PORT', '3306'),
'OPTIONS': { 'OPTIONS': {
'charset': 'utf8mb4', 'charset': 'utf8mb4',
@ -101,7 +102,9 @@ else:
AUTH_USER_MODEL = 'accounts.User' AUTH_USER_MODEL = 'accounts.User'
AUTH_PASSWORD_VALIDATORS = [ 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 # REST Framework
@ -116,6 +119,15 @@ REST_FRAMEWORK = {
'rest_framework.renderers.JSONRenderer', 'rest_framework.renderers.JSONRenderer',
), ),
'EXCEPTION_HANDLER': 'utils.log_center.custom_exception_handler', '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 # JWT settings
@ -149,6 +161,15 @@ STATIC_ROOT = BASE_DIR / 'staticfiles'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 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) # TOS (Volcano Engine Object Storage)
# ────────────────────────────────────────────── # ──────────────────────────────────────────────

View File

@ -1,6 +1,7 @@
from django.contrib import admin from django.contrib import admin
from django.urls import path, include from django.urls import path, include
from django.http import JsonResponse from django.http import JsonResponse
from django.conf import settings
def healthz(request): def healthz(request):
@ -9,7 +10,10 @@ def healthz(request):
urlpatterns = [ urlpatterns = [
path('healthz/', healthz), path('healthz/', healthz),
path('admin/', admin.site.urls),
path('api/v1/auth/', include('apps.accounts.urls')), path('api/v1/auth/', include('apps.accounts.urls')),
path('api/v1/', include('apps.generation.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: 管理员操作审计日志 ## 2026-03-16 — v0.8.4: 管理员操作审计日志
**状态**: ✅ 已完成 | **验收**: ✅ 通过(本地测试) **状态**: ✅ 已完成 | **验收**: ✅ 通过(本地测试)

View File

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

View File

@ -1,9 +1,18 @@
server_tokens off;
server { server {
listen 80; listen 80;
server_name _; server_name _;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.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 # API requests proxy to backend service
location /api/ { location /api/ {
proxy_pass http://video-backend:8000; proxy_pass http://video-backend:8000;

28
web/package-lock.json generated
View File

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@arco-design/web-react": "^2.64.0", "@arco-design/web-react": "^2.64.0",
"axios": "^1.13.6", "axios": "^1.13.6",
"dompurify": "^3.3.3",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"echarts-for-react": "^3.0.6", "echarts-for-react": "^3.0.6",
"react": "^18.3.1", "react": "^18.3.1",
@ -22,6 +23,7 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/dompurify": "^3.0.5",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
@ -1628,6 +1630,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1695,6 +1707,13 @@
"@types/react-router": "*" "@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": { "node_modules/@vitejs/plugin-react": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@ -2224,6 +2243,15 @@
"csstype": "^3.0.2" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

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

View File

@ -1,4 +1,5 @@
import { useRef, useEffect, useCallback, useState } from 'react'; import { useRef, useEffect, useCallback, useState } from 'react';
import DOMPurify from 'dompurify';
import { useInputBarStore } from '../store/inputBar'; import { useInputBarStore } from '../store/inputBar';
import type { UploadedFile } from '../types'; import type { UploadedFile } from '../types';
import styles from './PromptInput.module.css'; import styles from './PromptInput.module.css';
@ -35,7 +36,7 @@ export function PromptInput() {
const el = editorRef.current; const el = editorRef.current;
if (!el) return; if (!el) return;
if (el.innerHTML !== editorHtml) { 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 // 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) // This handles the case where editorHtml comes from backend (plain text only)
if (editorHtml && !editorHtml.includes('data-ref-id') && references.length > 0) { if (editorHtml && !editorHtml.includes('data-ref-id') && references.length > 0) {