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.
This commit is contained in:
chiguyong 2026-06-21 00:08:30 +08:00
parent 0028091f34
commit d6f222c2e0
2 changed files with 63 additions and 31 deletions

77
app.js
View File

@ -5,6 +5,14 @@
const API_BASE = '/api'; const API_BASE = '/api';
const TOKEN_KEY = 'eternal_ai_token'; const TOKEN_KEY = 'eternal_ai_token';
// 安全:转义 HTML防止存储型 XSS用于 innerHTML 插入的用户可控数据)
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, (ch) => {
const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
return map[ch];
});
}
function getToken() { function getToken() {
return localStorage.getItem(TOKEN_KEY) || ''; return localStorage.getItem(TOKEN_KEY) || '';
} }
@ -308,20 +316,25 @@
return; return;
} }
listEl.innerHTML = roles listEl.innerHTML = roles
.map( .map((role) => {
(role) => ` const id = escapeHtml(role.id);
<article class="role-card" data-role-id="${role.id}" role="button" tabindex="0" aria-label="${role.displayName}${role.desc || ''},每月${role.price}元"> const name = escapeHtml(role.displayName);
<img class="role-card__avatar" src="${role.avatar || 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=anime%20character%20portrait&image_size=square'}" alt="${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 `
<article class="role-card" data-role-id="${id}" role="button" tabindex="0" aria-label="${name}${desc},每月${price}元">
<img class="role-card__avatar" src="${avatar}" alt="${name}" />
<div class="role-card__info"> <div class="role-card__info">
<h3 class="role-card__name">${role.displayName}</h3> <h3 class="role-card__name">${name}</h3>
<p class="role-card__desc">${role.desc || ''}</p> <p class="role-card__desc">${desc}</p>
<span class="role-card__price">¥${role.price}</span> <span class="role-card__price">¥${price}</span>
</div> </div>
</article>` </article>`;
) })
.join(''); .join('');
} catch (err) { } catch (err) {
listEl.innerHTML = `<p style="text-align:center;color:var(--text-muted)">加载失败:${err.message}</p>`; listEl.innerHTML = `<p style="text-align:center;color:var(--text-muted)">加载失败:${escapeHtml(err.message)}</p>`;
} }
} }
@ -335,7 +348,7 @@
document.getElementById('detail-hero').style.backgroundImage = `url(${role.avatar || ''})`; document.getElementById('detail-hero').style.backgroundImage = `url(${role.avatar || ''})`;
document.getElementById('detail-role-name').textContent = role.displayName; document.getElementById('detail-role-name').textContent = role.displayName;
document.getElementById('detail-role-desc').textContent = role.desc || role.personality || ''; document.getElementById('detail-role-desc').textContent = role.desc || role.personality || '';
document.getElementById('detail-price').innerHTML = `<span class="detail-price__value">¥${role.price}</span><span class="detail-price__unit">/ 月</span>`; document.getElementById('detail-price').innerHTML = `<span class="detail-price__value">¥${escapeHtml(role.price)}</span><span class="detail-price__unit">/ 月</span>`;
document.getElementById('detail-actions-pre').hidden = false; document.getElementById('detail-actions-pre').hidden = false;
document.getElementById('detail-paid').hidden = true; document.getElementById('detail-paid').hidden = true;
@ -411,20 +424,25 @@
return; return;
} }
listEl.innerHTML = roles listEl.innerHTML = roles
.map( .map((role) => {
(role) => ` const id = escapeHtml(role.id);
<article class="role-card" data-role-id="${role.id}" role="button" tabindex="0" aria-label="${role.displayName}${role.status === 'running' ? '运行中' : '已停止'}"> const name = escapeHtml(role.displayName);
<img class="role-card__avatar" src="${role.avatar || 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=anime%20character%20portrait&image_size=square'}" alt="${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 `
<article class="role-card" data-role-id="${id}" role="button" tabindex="0" aria-label="${name}${statusText}">
<img class="role-card__avatar" src="${avatar}" alt="${name}" />
<div class="role-card__info"> <div class="role-card__info">
<h3 class="role-card__name">${role.displayName}</h3> <h3 class="role-card__name">${name}</h3>
<span class="role-card__status role-card__status--${role.status}">${role.status === 'running' ? '运行中' : '已停止'}</span> <span class="role-card__status role-card__status--${statusClass}">${statusText}</span>
</div> </div>
<button class="btn btn--small btn--outline" type="button" data-action="edit-role" data-role-id="${role.id}">编辑</button> <button class="btn btn--small btn--outline" type="button" data-action="edit-role" data-role-id="${id}">编辑</button>
</article>` </article>`;
) })
.join(''); .join('');
} catch (err) { } catch (err) {
listEl.innerHTML = `<p style="text-align:center;color:var(--text-muted)">加载失败:${err.message}</p>`; listEl.innerHTML = `<p style="text-align:center;color:var(--text-muted)">加载失败:${escapeHtml(err.message)}</p>`;
} }
} }
@ -437,16 +455,19 @@
return; return;
} }
listEl.innerHTML = income.records listEl.innerHTML = income.records
.map( .map((r) => {
(r) => ` const role = escapeHtml(r.role);
const time = escapeHtml(r.time);
const amount = r.amount.toFixed(2);
return `
<div class="income-record"> <div class="income-record">
<div class="income-record__info"> <div class="income-record__info">
<span class="income-record__role">${r.role}</span> <span class="income-record__role">${role}</span>
<span class="income-record__time">${r.time}</span> <span class="income-record__time">${time}</span>
</div> </div>
<span class="income-record__amount">+¥${r.amount.toFixed(2)}</span> <span class="income-record__amount">+¥${amount}</span>
</div>` </div>`;
) })
.join(''); .join('');
} }

View File

@ -1,9 +1,20 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const JWT_SECRET = process.env.JWT_SECRET || 'eternalai_jwt_secret_2026_change_in_prod';
const JWT_EXPIRES_IN = '7d'; 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) { function hashPassword(password) {
return bcrypt.hashSync(password, 10); return bcrypt.hashSync(password, 10);
@ -16,13 +27,13 @@ function verifyPassword(password, hash) {
// 生成 JWT // 生成 JWT
function signToken(userId) { function signToken(userId) {
return jwt.sign({ userId }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); return jwt.sign({ userId }, SECRET, { expiresIn: JWT_EXPIRES_IN });
} }
// 验证 JWT 并提取 userId // 验证 JWT 并提取 userId
function verifyToken(token) { function verifyToken(token) {
try { try {
const decoded = jwt.verify(token, JWT_SECRET); const decoded = jwt.verify(token, SECRET);
return decoded.userId; return decoded.userId;
} catch { } catch {
return null; return null;