fix(security): resolve 2 P0 issues - hardcoded JWT secret and stored XSS
Deploy EternalAI / deploy (push) Failing after 55m27s Details

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 fc53fa2e58
commit e7423f602f
2 changed files with 63 additions and 31 deletions

77
app.js
View File

@ -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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
return map[ch];
});
}
function getToken() {
return localStorage.getItem(TOKEN_KEY) || '';
}
@ -308,20 +316,25 @@
return;
}
listEl.innerHTML = roles
.map(
(role) => `
<article class="role-card" data-role-id="${role.id}" role="button" tabindex="0" aria-label="${role.displayName}${role.desc || ''},每月${role.price}元">
<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}" />
.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 `
<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">
<h3 class="role-card__name">${role.displayName}</h3>
<p class="role-card__desc">${role.desc || ''}</p>
<span class="role-card__price">¥${role.price}</span>
<h3 class="role-card__name">${name}</h3>
<p class="role-card__desc">${desc}</p>
<span class="role-card__price">¥${price}</span>
</div>
</article>`
)
</article>`;
})
.join('');
} 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-role-name').textContent = role.displayName;
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-paid').hidden = true;
@ -411,20 +424,25 @@
return;
}
listEl.innerHTML = roles
.map(
(role) => `
<article class="role-card" data-role-id="${role.id}" role="button" tabindex="0" aria-label="${role.displayName}${role.status === 'running' ? '运行中' : '已停止'}">
<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}" />
.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 `
<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">
<h3 class="role-card__name">${role.displayName}</h3>
<span class="role-card__status role-card__status--${role.status}">${role.status === 'running' ? '运行中' : '已停止'}</span>
<h3 class="role-card__name">${name}</h3>
<span class="role-card__status role-card__status--${statusClass}">${statusText}</span>
</div>
<button class="btn btn--small btn--outline" type="button" data-action="edit-role" data-role-id="${role.id}">编辑</button>
</article>`
)
<button class="btn btn--small btn--outline" type="button" data-action="edit-role" data-role-id="${id}">编辑</button>
</article>`;
})
.join('');
} 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;
}
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 `
<div class="income-record">
<div class="income-record__info">
<span class="income-record__role">${r.role}</span>
<span class="income-record__time">${r.time}</span>
<span class="income-record__role">${role}</span>
<span class="income-record__time">${time}</span>
</div>
<span class="income-record__amount">+¥${r.amount.toFixed(2)}</span>
</div>`
)
<span class="income-record__amount">+¥${amount}</span>
</div>`;
})
.join('');
}

View File

@ -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;