security: address HIGH findings (stored XSS, wildcard CORS, prod log leak)

H1 — stored XSS via v-html / innerHTML of unsanitized LLM/agent/interview/report
content: add DOMPurify; both renderMarkdown() now return DOMPurify.sanitize(html)
(Step4Report.vue, Step5Interaction.vue), and the formatAnswer innerHTML sink is
wrapped in DOMPurify.sanitize. All 8 HTML-injection sinks now sanitized; markdown
rendering preserved (DOMPurify secure defaults keep the md-* tags/classes).

H4 — wildcard CORS: CORS origins now Config.ALLOWED_ORIGINS (comma-separated env,
default localhost:3000) instead of '*'.

H5 — request bodies written to disk in cleartext: logger file level now follows
FLASK_DEBUG (INFO in prod), so the before_request body-debug log is suppressed in
production. (traceback-in-response across 53 handlers deferred to a separate refactor.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Yo-LRK 2026-06-13 17:32:36 +07:00
parent 8cb92768ae
commit 394638d426
8 changed files with 57 additions and 18 deletions

View File

@ -30,6 +30,10 @@ API_KEY=change_me_to_a_strong_api_key
# 使打包后的 UI 自动带上 X-API-Key。注意它会被打进客户端包、可被任何访问者提取见 README 安全说明)。
VITE_API_KEY=change_me_to_a_strong_api_key
# ===== CORS 允许来源H4=====
# 逗号分隔的前端来源;不再用通配 '*'。生产填前端域名,例如 https://app.example.com
ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
# ===== 模拟成本上限C3denial-of-wallet 防护)=====
# 客户端未传 max_rounds 时的默认轮数上限(完整长度模拟请按请求传 max_rounds 或调高此值)
# 默认 150 覆盖典型配置72h/30min=144 轮)以免悄悄截断标准演示

View File

@ -47,8 +47,8 @@ def create_app(config_class=Config):
logger.info("MiroFish Backend 启动中...")
logger.info("=" * 50)
# 启用CORS
CORS(app, resources={r"/api/*": {"origins": "*"}})
# 启用CORSH4限定来源为 Config.ALLOWED_ORIGINS默认本地前端源不再通配 '*'。
CORS(app, resources={r"/api/*": {"origins": Config.ALLOWED_ORIGINS}})
# API Key 鉴权C2所有 /api/* 端点强制鉴权。
# 客户端通过 `X-API-Key: <key>` 或 `Authorization: Bearer <key>` 传入。

View File

@ -34,6 +34,14 @@ class Config:
API_KEY = os.environ.get('API_KEY')
AUTH_ENABLED = os.environ.get('AUTH_ENABLED', 'true').strip().lower() not in ('false', '0', 'no', 'off')
# CORS 允许来源H4不再用通配 '*'。默认仅本地前端开发/预览源;生产用逗号分隔的
# ALLOWED_ORIGINS 指定前端域名(例如 https://app.example.com。'*' 仍可显式设置但不推荐。
ALLOWED_ORIGINS = [
o.strip() for o in os.environ.get(
'ALLOWED_ORIGINS', 'http://localhost:3000,http://127.0.0.1:3000'
).split(',') if o.strip()
]
# JSON配置 - 禁用ASCII转义让中文直接显示而不是 \uXXXX 格式)
JSON_AS_ASCII = False

View File

@ -27,17 +27,23 @@ def _ensure_utf8_stdout():
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'logs')
def setup_logger(name: str = 'mirofish', level: int = logging.DEBUG) -> logging.Logger:
def setup_logger(name: str = 'mirofish', level: int = None) -> logging.Logger:
"""
设置日志器
Args:
name: 日志器名称
level: 日志级别
level: 日志级别None 时按 FLASK_DEBUG 自动选择
Returns:
配置好的日志器
"""
# H5生产模式FLASK_DEBUG=false下用 INFO —— 避免把请求体/调试细节(可能含上传内容、
# 提示词、客户端传入的凭据)以明文写入轮转日志文件。开发模式仍用 DEBUG。
if level is None:
from ..config import Config
level = logging.DEBUG if Config.DEBUG else logging.INFO
# 确保日志目录存在
os.makedirs(LOG_DIR, exist_ok=True)
@ -71,7 +77,7 @@ def setup_logger(name: str = 'mirofish', level: int = logging.DEBUG) -> logging.
backupCount=5,
encoding='utf-8'
)
file_handler.setLevel(logging.DEBUG)
file_handler.setLevel(level)
file_handler.setFormatter(detailed_formatter)
# 2. 控制台处理器 - 简洁日志INFO及以上

View File

@ -10,6 +10,7 @@
"dependencies": {
"axios": "^1.14.0",
"d3": "^7.9.0",
"dompurify": "^3.4.10",
"vue": "^3.5.24",
"vue-i18n": "^11.3.0",
"vue-router": "^4.6.3"
@ -938,6 +939,13 @@
"dev": true,
"license": "MIT"
},
"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==",
"license": "MIT",
"optional": true
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.2.tgz",
@ -1435,7 +1443,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -1538,6 +1545,15 @@
"node": ">=0.4.0"
}
},
"node_modules/dompurify": {
"version": "3.4.10",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz",
"integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==",
"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",
@ -1913,7 +1929,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -2053,7 +2068,6 @@
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@ -2128,7 +2142,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-sfc": "3.5.25",

View File

@ -11,6 +11,7 @@
"dependencies": {
"axios": "^1.14.0",
"d3": "^7.9.0",
"dompurify": "^3.4.10",
"vue": "^3.5.24",
"vue-i18n": "^11.3.0",
"vue-router": "^4.6.3"

View File

@ -391,6 +391,7 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick, h, reactive } from 'vue'
import DOMPurify from 'dompurify'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { getAgentLog, getConsoleLog } from '../api/report'
@ -1531,11 +1532,14 @@ const InterviewDisplay = {
]),
h('div', {
class: ['qa-text', 'answer-text', { 'placeholder-text': isPlaceholder }],
innerHTML: isPlaceholder
? answerText
: formatAnswer(answerText, isExpanded)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\n/g, '<br>')
// H1answerText LLM innerHTML XSS
innerHTML: DOMPurify.sanitize(
isPlaceholder
? answerText
: formatAnswer(answerText, isExpanded)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\n/g, '<br>')
)
}),
// Expand/Collapse Button
!isPlaceholder && answerText.length > 400 && h('button', {
@ -1973,7 +1977,8 @@ const renderMarkdown = (content) => {
}
html = tokens.join('')
return html
// H1 HTML LLM / XSS
return DOMPurify.sanitize(html)
}
const getTimelineItemClass = (log, idx, total) => {

View File

@ -412,6 +412,7 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import DOMPurify from 'dompurify'
import { useI18n } from 'vue-i18n'
import { chatWithReport, getReport, getAgentLog } from '../api/report'
import { interviewAgents, getSimulationProfilesRealtime } from '../api/simulation'
@ -638,7 +639,8 @@ const renderMarkdown = (content) => {
}
html = tokens.join('')
return html
// H1 HTML LLM/访/ XSS
return DOMPurify.sanitize(html)
}
// Chat Methods