From d6f222c2e0df9f4816c79785c6e4f7ac389c2f96 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Sun, 21 Jun 2026 00:08:30 +0800 Subject: [PATCH] fix(security): resolve 2 P0 issues - hardcoded JWT secret and stored XSS P0-1: JWT secret hardcoded fallback (src/lib/auth.js) - Remove insecure hardcoded default 'eternalai_jwt_secret_2026_change_in_prod' - Fail-fast in production: throw error if JWT_SECRET env var is missing - Dev/test: print security warning and use dev-only temporary secret P0-2: Stored XSS via innerHTML (app.js) - Add escapeHtml() utility function (escapes & < > " ') - Escape all user-controlled data in innerHTML templates: - Role library list (id, displayName, desc, avatar, price) - Creator center role list (id, displayName, avatar, status) - Role detail price - Income records (role, time) - Error messages in catch blocks All 35 E2E tests pass. --- app.js | 77 +++++++++++++++++++++++++++++++------------------ src/lib/auth.js | 17 +++++++++-- 2 files changed, 63 insertions(+), 31 deletions(-) diff --git a/app.js b/app.js index ddb42bd..fffe29e 100644 --- a/app.js +++ b/app.js @@ -5,6 +5,14 @@ const API_BASE = '/api'; const TOKEN_KEY = 'eternal_ai_token'; + // 安全:转义 HTML,防止存储型 XSS(用于 innerHTML 插入的用户可控数据) + function escapeHtml(str) { + return String(str).replace(/[&<>"']/g, (ch) => { + const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + return map[ch]; + }); + } + function getToken() { return localStorage.getItem(TOKEN_KEY) || ''; } @@ -308,20 +316,25 @@ return; } listEl.innerHTML = roles - .map( - (role) => ` -
- ${role.displayName} + .map((role) => { + const id = escapeHtml(role.id); + const name = escapeHtml(role.displayName); + const desc = escapeHtml(role.desc || ''); + const avatar = escapeHtml(role.avatar || 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=anime%20character%20portrait&image_size=square'); + const price = escapeHtml(role.price); + return ` +
+ ${name}
-

${role.displayName}

-

${role.desc || ''}

- ¥${role.price} +

${name}

+

${desc}

+ ¥${price}
-
` - ) +
`; + }) .join(''); } catch (err) { - listEl.innerHTML = `

加载失败:${err.message}

`; + listEl.innerHTML = `

加载失败:${escapeHtml(err.message)}

`; } } @@ -335,7 +348,7 @@ document.getElementById('detail-hero').style.backgroundImage = `url(${role.avatar || ''})`; document.getElementById('detail-role-name').textContent = role.displayName; document.getElementById('detail-role-desc').textContent = role.desc || role.personality || ''; - document.getElementById('detail-price').innerHTML = `¥${role.price}/ 月`; + document.getElementById('detail-price').innerHTML = `¥${escapeHtml(role.price)}/ 月`; document.getElementById('detail-actions-pre').hidden = false; document.getElementById('detail-paid').hidden = true; @@ -411,20 +424,25 @@ return; } listEl.innerHTML = roles - .map( - (role) => ` -
- ${role.displayName} + .map((role) => { + const id = escapeHtml(role.id); + const name = escapeHtml(role.displayName); + const avatar = escapeHtml(role.avatar || 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=anime%20character%20portrait&image_size=square'); + const statusText = role.status === 'running' ? '运行中' : '已停止'; + const statusClass = escapeHtml(role.status); + return ` +
+ ${name}
-

${role.displayName}

- ${role.status === 'running' ? '运行中' : '已停止'} +

${name}

+ ${statusText}
- -
` - ) + +
`; + }) .join(''); } catch (err) { - listEl.innerHTML = `

加载失败:${err.message}

`; + listEl.innerHTML = `

加载失败:${escapeHtml(err.message)}

`; } } @@ -437,16 +455,19 @@ return; } listEl.innerHTML = income.records - .map( - (r) => ` + .map((r) => { + const role = escapeHtml(r.role); + const time = escapeHtml(r.time); + const amount = r.amount.toFixed(2); + return `
- ${r.role} - ${r.time} + ${role} + ${time}
- +¥${r.amount.toFixed(2)} -
` - ) + +¥${amount} + `; + }) .join(''); } diff --git a/src/lib/auth.js b/src/lib/auth.js index 1a110de..62e5e4c 100644 --- a/src/lib/auth.js +++ b/src/lib/auth.js @@ -1,9 +1,20 @@ const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); -const JWT_SECRET = process.env.JWT_SECRET || 'eternalai_jwt_secret_2026_change_in_prod'; const JWT_EXPIRES_IN = '7d'; +// 安全:生产环境必须配置 JWT_SECRET,杜绝硬编码密钥 +const JWT_SECRET = process.env.JWT_SECRET; +if (!JWT_SECRET) { + if (process.env.NODE_ENV === 'production') { + throw new Error( + 'JWT_SECRET 环境变量未设置。请在 .env 文件中配置一个随机密钥(可用 `openssl rand -hex 32` 生成)。' + ); + } + console.warn('[安全警告] JWT_SECRET 未设置,使用开发环境临时密钥。请勿在生产环境使用。'); +} +const SECRET = JWT_SECRET || 'dev_only_insecure_secret_do_not_use_in_production'; + // 哈希密码 function hashPassword(password) { return bcrypt.hashSync(password, 10); @@ -16,13 +27,13 @@ function verifyPassword(password, hash) { // 生成 JWT function signToken(userId) { - return jwt.sign({ userId }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); + return jwt.sign({ userId }, SECRET, { expiresIn: JWT_EXPIRES_IN }); } // 验证 JWT 并提取 userId function verifyToken(token) { try { - const decoded = jwt.verify(token, JWT_SECRET); + const decoded = jwt.verify(token, SECRET); return decoded.userId; } catch { return null;