diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index a50b6f6..0f63510 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -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 diff --git a/backend/config/settings.py b/backend/config/settings.py index 5fd59d0..4ce911d 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -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) # ────────────────────────────────────────────── diff --git a/backend/config/urls.py b/backend/config/urls.py index ccddedc..ecb4123 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -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)) diff --git a/docs/changelog.md b/docs/changelog.md index 46b2776..f670b28 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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: 管理员操作审计日志 **状态**: ✅ 已完成 | **验收**: ✅ 通过(本地测试) diff --git a/k8s/backend-deployment.yaml b/k8s/backend-deployment.yaml index e3fa8cf..915d396 100644 --- a/k8s/backend-deployment.yaml +++ b/k8s/backend-deployment.yaml @@ -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 diff --git a/web/nginx.conf b/web/nginx.conf index 29e97c3..2f0346d 100644 --- a/web/nginx.conf +++ b/web/nginx.conf @@ -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; diff --git a/web/package-lock.json b/web/package-lock.json index 80dec7f..f4304c5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 9e4f49d..cff6ed8 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/components/PromptInput.tsx b/web/src/components/PromptInput.tsx index d3fc0f2..bc08182 100644 --- a/web/src/components/PromptInput.tsx +++ b/web/src/components/PromptInput.tsx @@ -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) {