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