fix(security): resolve 2 P0 issues - hardcoded JWT secret and stored XSS
Deploy EternalAI / deploy (push) Failing after 55m27s
Details
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:
parent
fc53fa2e58
commit
e7423f602f
77
app.js
77
app.js
|
|
@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||||
|
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('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue