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:
parent
4c0605e589
commit
d9a12af078
@ -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
|
||||
|
||||
@ -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)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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: 管理员操作审计日志
|
||||
|
||||
**状态**: ✅ 已完成 | **验收**: ✅ 通过(本地测试)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
28
web/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user