fix(security): apply ce-code-review fixes (1 P0, 6 P1, 6 P2)

P0:
- sync-token.js: remove dead consumeSyncToken code, use crypto.randomBytes for jti

P1:
- admin-sync.js: add SSRF protection (protocol/host allowlist, block private IPs in prod)
- admin-sync.js: add POST /:roleId/reset for syncing state recovery
- admin-sync.js: use BASE_URL env var instead of forgeable Host header
- admin-sync.js: guard catch block to only rollback syncing->failed (not approved)
- admin-config.js: write-protect SYNC_SECRET from manual override
- admin-config.js: add updatedAt to PUT response
- roles.js: reset reviewStatus to pending_review when editing synced role
- roles.js: filter GET /:id by reviewStatus=synced
- scripts/migrate-existing-roles-to-synced.js: data migration for existing roles

P2:
- server.js: mock-hermes use explicit allowlist [development, test]
- auth.js: ADMIN_JWT_SECRET fail-fast in production
- hermes.js: unify error messages to Chinese
- admin-sync.js: do not leak err.message in response
- admin.js: validate pagination params (page/pageSize bounds)

All 54 E2E tests pass (19 admin-sync-flow + 35 existing).
This commit is contained in:
chiguyong 2026-06-21 16:14:53 +08:00
parent 848939dc21
commit a921f64ee0
9 changed files with 250 additions and 57 deletions

View File

@ -0,0 +1,79 @@
// 数据迁移脚本将现有角色reviewStatus 为空或默认 pending_review标记为 synced
// 背景:管理员审核 + Hermes 同步流程上线后,角色库过滤改为 reviewStatus='synced'。
// 现有角色是在审核流程上线前创建的,直接标记为 synced 以保持向后兼容。
//
// 用法: node scripts/migrate-existing-roles-to-synced.js
//
// 安全说明:
// - 仅迁移 status='running' 且 reviewStatus='pending_review' 的角色
// - 已有 reviewStatusapproved/synced/rejected/failed的角色不受影响
// - 执行前会打印待迁移数量,需用户确认(除非传入 --yes 跳过确认)
const prisma = require('../src/lib/prisma');
async function main() {
const skipConfirm = process.argv.includes('--yes');
// 查找待迁移角色status='running' 且 reviewStatus='pending_review'
// 这些是审核流程上线前创建的角色
const candidates = await prisma.role.findMany({
where: {
status: 'running',
reviewStatus: 'pending_review',
},
select: { id: true, displayName: true, createdAt: true },
});
console.log(`找到 ${candidates.length} 个待迁移角色status=running 且 reviewStatus=pending_review`);
if (candidates.length === 0) {
console.log('无需迁移');
await prisma.$disconnect();
return;
}
if (!skipConfirm) {
console.log('\n待迁移角色列表前 10 个):');
candidates.slice(0, 10).forEach((r, i) => {
console.log(` ${i + 1}. ${r.displayName} (id=${r.id}, createdAt=${r.createdAt.toISOString()})`);
});
if (candidates.length > 10) {
console.log(` ... 还有 ${candidates.length - 10}`);
}
console.log('\n将这些角色标记为 synced已同步此操作不可逆。');
console.log('确认请输入 yes否则取消:');
const readline = require('readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise((resolve) => {
rl.question('> ', (a) => {
rl.close();
resolve(a.trim().toLowerCase());
});
});
if (answer !== 'yes') {
console.log('已取消');
await prisma.$disconnect();
return;
}
}
const result = await prisma.role.updateMany({
where: {
status: 'running',
reviewStatus: 'pending_review',
},
data: {
reviewStatus: 'synced',
syncedAt: new Date(),
},
});
console.log(`\n迁移完成:${result.count} 个角色已标记为 synced`);
await prisma.$disconnect();
}
main().catch((err) => {
console.error('迁移失败:', err);
process.exit(1);
});

View File

@ -20,8 +20,9 @@ app.use('/api/admin', require('./src/routes/admin'));
app.use('/api/admin/config', require('./src/routes/admin-config')); app.use('/api/admin/config', require('./src/routes/admin-config'));
app.use('/api/admin/sync', require('./src/routes/admin-sync')); app.use('/api/admin/sync', require('./src/routes/admin-sync'));
// Mock Hermes 端点(仅非 production 环境) // Mock Hermes 端点(仅 development/test 环境注册,显式 allowlist 防止误开放)
if (process.env.NODE_ENV !== 'production') { const MOCK_HERMES_ALLOWED_ENVS = ['development', 'test'];
if (MOCK_HERMES_ALLOWED_ENVS.includes(process.env.NODE_ENV || 'development')) {
app.use('/api/mock-hermes', require('./src/routes/mock-hermes')); app.use('/api/mock-hermes', require('./src/routes/mock-hermes'));
} }

View File

@ -19,9 +19,17 @@ if (!JWT_SECRET) {
const SECRET = JWT_SECRET || 'dev_only_insecure_secret_do_not_use_in_production'; const SECRET = JWT_SECRET || 'dev_only_insecure_secret_do_not_use_in_production';
// Admin JWT 使用独立 secret防止跨角色伪造 // Admin JWT 使用独立 secret防止跨角色伪造
const ADMIN_JWT_SECRET = process.env.ADMIN_JWT_SECRET || (process.env.NODE_ENV === 'production' // 生产环境必须配置 ADMIN_JWT_SECRET否则 fail-fast
? null const ADMIN_JWT_SECRET = process.env.ADMIN_JWT_SECRET;
: 'dev_only_admin_insecure_secret'); if (!ADMIN_JWT_SECRET) {
if (process.env.NODE_ENV === 'production') {
throw new Error(
'ADMIN_JWT_SECRET 环境变量未设置。请在 .env 文件中配置一个独立于 JWT_SECRET 的随机密钥(可用 `openssl rand -hex 32` 生成)。'
);
}
console.warn('[安全警告] ADMIN_JWT_SECRET 未设置,使用开发环境临时密钥。请勿在生产环境使用。');
}
const ADMIN_SECRET = ADMIN_JWT_SECRET || 'dev_only_admin_insecure_secret';
// 哈希密码 // 哈希密码
function hashPassword(password) { function hashPassword(password) {
@ -111,13 +119,13 @@ async function apiKeyMiddleware(req, res, next) {
// 生成 Admin JWT // 生成 Admin JWT
function adminSignToken(adminId) { function adminSignToken(adminId) {
return jwt.sign({ adminId, role: 'admin' }, ADMIN_JWT_SECRET, { expiresIn: ADMIN_JWT_EXPIRES_IN }); return jwt.sign({ adminId, role: 'admin' }, ADMIN_SECRET, { expiresIn: ADMIN_JWT_EXPIRES_IN });
} }
// 验证 Admin JWT // 验证 Admin JWT
function adminVerifyToken(token) { function adminVerifyToken(token) {
try { try {
const decoded = jwt.verify(token, ADMIN_JWT_SECRET); const decoded = jwt.verify(token, ADMIN_SECRET);
if (decoded.role !== 'admin') return null; if (decoded.role !== 'admin') return null;
return decoded.adminId; return decoded.adminId;
} catch { } catch {

View File

@ -1,27 +1,13 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const prisma = require('./prisma'); const prisma = require('./prisma');
const SYNC_TOKEN_EXPIRES_IN = '5m'; const SYNC_TOKEN_EXPIRES_IN = '5m';
// 内存 Set 记录已消费的 sync_token jti5 分钟后自动清理
const consumedTokens = new Map();
// 清理过期 token每 5 分钟调用一次)
function cleanupConsumedTokens() {
const now = Date.now();
for (const [jti, expiry] of consumedTokens.entries()) {
if (now > expiry) {
consumedTokens.delete(jti);
}
}
}
setInterval(cleanupConsumedTokens, 5 * 60 * 1000);
// 从 SystemConfig 获取 SYNC_SECRET不存在则自动生成 // 从 SystemConfig 获取 SYNC_SECRET不存在则自动生成
async function getSyncSecret() { async function getSyncSecret() {
let config = await prisma.systemConfig.findUnique({ where: { key: 'SYNC_SECRET' } }); let config = await prisma.systemConfig.findUnique({ where: { key: 'SYNC_SECRET' } });
if (!config) { if (!config) {
const crypto = require('crypto');
const secret = crypto.randomBytes(32).toString('hex'); const secret = crypto.randomBytes(32).toString('hex');
config = await prisma.systemConfig.create({ config = await prisma.systemConfig.create({
data: { key: 'SYNC_SECRET', value: secret }, data: { key: 'SYNC_SECRET', value: secret },
@ -30,38 +16,28 @@ async function getSyncSecret() {
return config.value; return config.value;
} }
// 生成 sync_token // 生成 sync_token5 分钟过期jti 用 CSPRNG 生成)
async function generateSyncToken(roleId, adminId) { async function generateSyncToken(roleId, adminId) {
const secret = await getSyncSecret(); const secret = await getSyncSecret();
const jti = `${roleId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; const jti = crypto.randomBytes(16).toString('hex');
return jwt.sign({ roleId, adminId, jti, type: 'sync' }, secret, { expiresIn: SYNC_TOKEN_EXPIRES_IN }); return jwt.sign({ roleId, adminId, jti, type: 'sync' }, secret, { expiresIn: SYNC_TOKEN_EXPIRES_IN });
} }
// 验证 sync_token返回 payload 或 null // 验证 sync_token返回 payload 或 null
// 注意sync_token 在 5 分钟有效期内可多次使用Hermes 需拉取 SOUL.md + config.yaml 两个文件)
// 重放防护依赖 5 分钟短过期 + HTTPS 传输 + roleId 绑定
async function verifySyncToken(token) { async function verifySyncToken(token) {
try { try {
const secret = await getSyncSecret(); const secret = await getSyncSecret();
const decoded = jwt.verify(token, secret); const decoded = jwt.verify(token, secret);
if (decoded.type !== 'sync') return null; if (decoded.type !== 'sync') return null;
// 检查是否已消费
if (consumedTokens.has(decoded.jti)) {
return null;
}
return decoded; return decoded;
} catch { } catch {
return null; return null;
} }
} }
// 标记 sync_token 为已消费
function consumeSyncToken(jti) {
consumedTokens.set(jti, Date.now() + 5 * 60 * 1000);
}
module.exports = { module.exports = {
generateSyncToken, generateSyncToken,
verifySyncToken, verifySyncToken,
consumeSyncToken,
}; };

View File

@ -7,6 +7,10 @@ const router = express.Router();
// 敏感配置项的值需要脱敏显示 // 敏感配置项的值需要脱敏显示
const SENSITIVE_KEYS = ['SYNC_SECRET']; const SENSITIVE_KEYS = ['SYNC_SECRET'];
// 受保护配置项:禁止通过 PUT 接口直接覆写
// SYNC_SECRET 由系统自动生成并轮换,管理员手动覆写会导致已知密钥攻击
const PROTECTED_KEYS = ['SYNC_SECRET'];
// 获取所有配置 // 获取所有配置
router.get('/', adminAuthMiddleware, async (req, res) => { router.get('/', adminAuthMiddleware, async (req, res) => {
try { try {
@ -30,13 +34,29 @@ router.put('/:key', adminAuthMiddleware, async (req, res) => {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return res.status(400).json({ error: 'value 不能为空' }); return res.status(400).json({ error: 'value 不能为空' });
} }
if (typeof value !== 'string' || value.length === 0) {
return res.status(400).json({ error: 'value 必须为非空字符串' });
}
// 受保护配置项禁止通过此接口覆写
if (PROTECTED_KEYS.includes(req.params.key)) {
return res.status(403).json({
error: `配置项 ${req.params.key} 受系统保护,禁止手动修改(由系统自动管理)`,
});
}
const config = await prisma.systemConfig.upsert({ const config = await prisma.systemConfig.upsert({
where: { key: req.params.key }, where: { key: req.params.key },
update: { value }, update: { value },
create: { key: req.params.key, value }, create: { key: req.params.key, value },
}); });
res.json({ config: { key: config.key, value: SENSITIVE_KEYS.includes(config.key) ? '***' : config.value } }); res.json({
config: {
key: config.key,
value: SENSITIVE_KEYS.includes(config.key) ? '***' : config.value,
updatedAt: config.updatedAt,
},
});
} catch (err) { } catch (err) {
console.error('更新系统配置失败:', err); console.error('更新系统配置失败:', err);
res.status(500).json({ error: '更新失败' }); res.status(500).json({ error: '更新失败' });

View File

@ -6,6 +6,56 @@ const { postSync } = require('../lib/hermes-client');
const router = express.Router(); const router = express.Router();
// SSRF 防护:校验 webhookUrl 协议与主机
// - 仅允许 http/https开发环境允许 http生产环境强制 https
// - 禁止指向内网/回环地址10.x、127.x、169.254.x、172.16-31.x、192.168.x、::1、fc00::/7
function isPrivateIPv4(hostname) {
const parts = hostname.split('.').map(Number);
if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return false;
const [a, b] = parts;
if (a === 10) return true;
if (a === 127) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 0) return true;
return false;
}
function validateWebhookUrl(rawUrl) {
if (!rawUrl || typeof rawUrl !== 'string') {
return { ok: false, error: 'webhook URL 不能为空' };
}
let url;
try {
url = new URL(rawUrl);
} catch {
return { ok: false, error: 'webhook URL 格式无效' };
}
const hostname = url.hostname.toLowerCase();
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return { ok: false, error: 'webhook URL 协议仅支持 http/https' };
}
const isProduction = process.env.NODE_ENV === 'production';
if (isProduction && url.protocol !== 'https:') {
return { ok: false, error: '生产环境 webhook URL 必须使用 https' };
}
// 生产环境禁止指向内网/回环地址SSRF 防护)
// 非生产环境允许 localhost用于 mock-hermes 测试)
if (isProduction) {
if (isPrivateIPv4(hostname)) {
return { ok: false, error: 'webhook URL 不允许指向内网地址' };
}
if (hostname === 'localhost' || hostname === '::1' || hostname.endsWith('.local')) {
return { ok: false, error: 'webhook URL 不允许指向本地地址' };
}
if (hostname.startsWith('fc') || hostname.startsWith('fd') || hostname.startsWith('fe80')) {
return { ok: false, error: 'webhook URL 不允许指向内网地址' };
}
}
return { ok: true, url };
}
// 管理员发起同步 // 管理员发起同步
router.post('/:roleId', adminAuthMiddleware, async (req, res) => { router.post('/:roleId', adminAuthMiddleware, async (req, res) => {
try { try {
@ -41,8 +91,16 @@ router.post('/:roleId', adminAuthMiddleware, async (req, res) => {
return res.status(400).json({ error: '未配置 Hermes webhook URL' }); return res.status(400).json({ error: '未配置 Hermes webhook URL' });
} }
// SSRF 防护:校验 webhookUrl
const urlCheck = validateWebhookUrl(hermesUrl);
if (!urlCheck.ok) {
return res.status(400).json({ error: urlCheck.error });
}
hermesUrl = urlCheck.url.toString();
// 获取 EternalAI 自身基础 URL供 Hermes 回调拉取文件) // 获取 EternalAI 自身基础 URL供 Hermes 回调拉取文件)
let baseUrl = req.protocol + '://' + req.get('host'); // 优先使用环境变量 BASE_URL避免 Host 头被伪造
const baseUrl = process.env.BASE_URL || (req.protocol + '://' + req.get('host'));
// 生成 sync_token // 生成 sync_token
const syncToken = await generateSyncToken(role.id, req.adminId); const syncToken = await generateSyncToken(role.id, req.adminId);
@ -87,19 +145,47 @@ router.post('/:roleId', adminAuthMiddleware, async (req, res) => {
res.json({ role: updated, profileId: result.profileId }); res.json({ role: updated, profileId: result.profileId });
} catch (err) { } catch (err) {
console.error('同步失败:', err.message); console.error('同步失败:', err);
// 同步失败,更新状态 // 同步失败,仅当当前状态为 syncing 时才回滚为 failed避免误覆盖 approved
try { try {
await prisma.role.update({ const current = await prisma.role.findUnique({
where: { id: req.params.roleId }, where: { id: req.params.roleId },
data: { reviewStatus: 'failed' }, select: { reviewStatus: true },
}); });
if (current && current.reviewStatus === 'syncing') {
await prisma.role.update({
where: { id: req.params.roleId },
data: { reviewStatus: 'failed' },
});
}
} catch (updateErr) { } catch (updateErr) {
console.error('更新失败状态出错:', updateErr); console.error('更新失败状态出错:', updateErr);
} }
res.status(500).json({ error: err.message || '同步失败' }); res.status(500).json({ error: '同步失败,请稍后重试或检查 Hermes 服务状态' });
}
});
// 管理员强制重置 syncing 卡死状态(恢复路径)
router.post('/:roleId/reset', adminAuthMiddleware, async (req, res) => {
try {
const role = await prisma.role.findUnique({ where: { id: req.params.roleId } });
if (!role) {
return res.status(404).json({ error: '角色不存在' });
}
if (role.reviewStatus !== 'syncing') {
return res.status(400).json({ error: `当前状态为 ${role.reviewStatus},仅 syncing 状态可重置` });
}
const updated = await prisma.role.update({
where: { id: req.params.roleId },
data: { reviewStatus: 'failed' },
select: { id: true, displayName: true, reviewStatus: true },
});
res.json({ role: updated });
} catch (err) {
console.error('重置同步状态失败:', err);
res.status(500).json({ error: '重置失败' });
} }
}); });

View File

@ -7,15 +7,21 @@ const router = express.Router();
// 获取待审核列表 // 获取待审核列表
router.get('/reviews', adminAuthMiddleware, async (req, res) => { router.get('/reviews', adminAuthMiddleware, async (req, res) => {
try { try {
const { status, page = 1, pageSize = 20 } = req.query; const { status } = req.query;
// 分页参数校验:默认 page=1, pageSize=20限制最大 100
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(req.query.pageSize) || 20)));
if (Number.isNaN(page) || Number.isNaN(pageSize)) {
return res.status(400).json({ error: 'page 和 pageSize 必须为正整数' });
}
const where = status ? { reviewStatus: status } : { reviewStatus: 'pending_review' }; const where = status ? { reviewStatus: status } : { reviewStatus: 'pending_review' };
const [roles, total] = await Promise.all([ const [roles, total] = await Promise.all([
prisma.role.findMany({ prisma.role.findMany({
where, where,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
skip: (Number(page) - 1) * Number(pageSize), skip: (page - 1) * pageSize,
take: Number(pageSize), take: pageSize,
select: { select: {
id: true, id: true,
displayName: true, displayName: true,
@ -30,7 +36,7 @@ router.get('/reviews', adminAuthMiddleware, async (req, res) => {
prisma.role.count({ where }), prisma.role.count({ where }),
]); ]);
res.json({ roles, total, page: Number(page), pageSize: Number(pageSize) }); res.json({ roles, total, page, pageSize });
} catch (err) { } catch (err) {
console.error('获取审核列表失败:', err); console.error('获取审核列表失败:', err);
res.status(500).json({ error: '服务器错误' }); res.status(500).json({ error: '服务器错误' });

View File

@ -61,7 +61,7 @@ async function hermesAuthMiddleware(req, res, next) {
select: { creatorId: true }, select: { creatorId: true },
}); });
if (!role) { if (!role) {
return res.status(404).json({ error: 'Role not found' }); return res.status(404).json({ error: '角色不存在' });
} }
req.userId = role.creatorId; req.userId = role.creatorId;
req.authMethod = 'sync_token'; req.authMethod = 'sync_token';
@ -85,18 +85,18 @@ router.get('/roles/:id/SOUL.md', hermesAuthMiddleware, async (req, res) => {
select: { id: true, creatorId: true, displayName: true, soulMd: true }, select: { id: true, creatorId: true, displayName: true, soulMd: true },
}); });
if (!role) { if (!role) {
return res.status(404).json({ error: 'Role not found' }); return res.status(404).json({ error: '角色不存在' });
} }
if (role.creatorId !== req.userId) { if (role.creatorId !== req.userId) {
return res.status(403).json({ error: 'Forbidden: not the role owner' }); return res.status(403).json({ error: '无权访问该角色' });
} }
if (!role.soulMd) { if (!role.soulMd) {
return res.status(404).json({ error: 'SOUL.md not generated for this role' }); return res.status(404).json({ error: '该角色尚未生成 SOUL.md' });
} }
res.type('text/plain').send(role.soulMd); res.type('text/plain').send(role.soulMd);
} catch (err) { } catch (err) {
console.error('获取 SOUL.md 失败:', err); console.error('获取 SOUL.md 失败:', err);
res.status(500).json({ error: 'Server error' }); res.status(500).json({ error: '服务器错误' });
} }
}); });
@ -118,16 +118,16 @@ router.get('/roles/:id/config.yaml', hermesAuthMiddleware, async (req, res) => {
}, },
}); });
if (!role) { if (!role) {
return res.status(404).json({ error: 'Role not found' }); return res.status(404).json({ error: '角色不存在' });
} }
if (role.creatorId !== req.userId) { if (role.creatorId !== req.userId) {
return res.status(403).json({ error: 'Forbidden: not the role owner' }); return res.status(403).json({ error: '无权访问该角色' });
} }
const config = adaptToHermesConfig(role); const config = adaptToHermesConfig(role);
res.type('text/plain').send(config); res.type('text/plain').send(config);
} catch (err) { } catch (err) {
console.error('获取 config.yaml 失败:', err); console.error('获取 config.yaml 失败:', err);
res.status(500).json({ error: 'Server error' }); res.status(500).json({ error: '服务器错误' });
} }
}); });

View File

@ -26,7 +26,7 @@ router.get('/', async (req, res) => {
} }
}); });
// 获取角色详情 // 获取角色详情(仅显示已同步完成的角色)
router.get('/:id', async (req, res) => { router.get('/:id', async (req, res) => {
try { try {
const role = await prisma.role.findUnique({ const role = await prisma.role.findUnique({
@ -46,11 +46,16 @@ router.get('/:id', async (req, res) => {
speechStyle: true, speechStyle: true,
greeting: true, greeting: true,
creatorId: true, creatorId: true,
reviewStatus: true,
}, },
}); });
if (!role) { if (!role) {
return res.status(404).json({ error: '角色不存在' }); return res.status(404).json({ error: '角色不存在' });
} }
// 角色库仅展示已同步的角色
if (role.reviewStatus !== 'synced') {
return res.status(404).json({ error: '角色不存在' });
}
res.json({ role }); res.json({ role });
} catch (err) { } catch (err) {
console.error('获取角色详情失败:', err); console.error('获取角色详情失败:', err);
@ -144,6 +149,9 @@ router.put('/:id', authMiddleware, async (req, res) => {
} }
const data = req.body; const data = req.body;
// 若编辑已同步synced的角色重置为 pending_review需重新审核 + 同步
// 防止 Hermes 端的 profile 与 EternalAI 端的角色数据不一致
const shouldResetReview = existing.reviewStatus === 'synced';
const role = await prisma.role.update({ const role = await prisma.role.update({
where: { id: req.params.id }, where: { id: req.params.id },
data: { data: {
@ -171,10 +179,19 @@ router.put('/:id', authMiddleware, async (req, res) => {
desc: data.desc ?? existing.desc, desc: data.desc ?? existing.desc,
price: parseFloat(data.price) || existing.price, price: parseFloat(data.price) || existing.price,
status: data.status ?? existing.status, status: data.status ?? existing.status,
// 编辑后重置审核状态(仅当当前为 synced 时)
...(shouldResetReview
? { reviewStatus: 'pending_review', reviewNote: null, qrCodeUrl: null, syncedAt: null }
: {}),
}, },
}); });
res.json({ role }); res.json({
role,
reviewReset: shouldResetReview
? '角色已编辑,审核状态已重置为 pending_review需重新审核与同步'
: null,
});
} catch (err) { } catch (err) {
console.error('编辑角色失败:', err); console.error('编辑角色失败:', err);
res.status(500).json({ error: '编辑失败' }); res.status(500).json({ error: '编辑失败' });