From a921f64ee00f81b2792788e4c40f943fef7f1a41 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Sun, 21 Jun 2026 16:14:53 +0800 Subject: [PATCH] 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). --- scripts/migrate-existing-roles-to-synced.js | 79 +++++++++++++++++ server.js | 5 +- src/lib/auth.js | 18 ++-- src/lib/sync-token.js | 34 ++----- src/routes/admin-config.js | 22 ++++- src/routes/admin-sync.js | 98 +++++++++++++++++++-- src/routes/admin.js | 14 ++- src/routes/hermes.js | 16 ++-- src/routes/roles.js | 21 ++++- 9 files changed, 250 insertions(+), 57 deletions(-) create mode 100644 scripts/migrate-existing-roles-to-synced.js diff --git a/scripts/migrate-existing-roles-to-synced.js b/scripts/migrate-existing-roles-to-synced.js new file mode 100644 index 0000000..71a7f0a --- /dev/null +++ b/scripts/migrate-existing-roles-to-synced.js @@ -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' 的角色 +// - 已有 reviewStatus(approved/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); +}); diff --git a/server.js b/server.js index 89ab4aa..63fd456 100644 --- a/server.js +++ b/server.js @@ -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/sync', require('./src/routes/admin-sync')); -// Mock Hermes 端点(仅非 production 环境) -if (process.env.NODE_ENV !== 'production') { +// Mock Hermes 端点(仅 development/test 环境注册,显式 allowlist 防止误开放) +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')); } diff --git a/src/lib/auth.js b/src/lib/auth.js index e82ab19..77bc1de 100644 --- a/src/lib/auth.js +++ b/src/lib/auth.js @@ -19,9 +19,17 @@ if (!JWT_SECRET) { const SECRET = JWT_SECRET || 'dev_only_insecure_secret_do_not_use_in_production'; // Admin JWT 使用独立 secret,防止跨角色伪造 -const ADMIN_JWT_SECRET = process.env.ADMIN_JWT_SECRET || (process.env.NODE_ENV === 'production' - ? null - : 'dev_only_admin_insecure_secret'); +// 生产环境必须配置 ADMIN_JWT_SECRET,否则 fail-fast +const ADMIN_JWT_SECRET = process.env.ADMIN_JWT_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) { @@ -111,13 +119,13 @@ async function apiKeyMiddleware(req, res, next) { // 生成 Admin JWT 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 function adminVerifyToken(token) { try { - const decoded = jwt.verify(token, ADMIN_JWT_SECRET); + const decoded = jwt.verify(token, ADMIN_SECRET); if (decoded.role !== 'admin') return null; return decoded.adminId; } catch { diff --git a/src/lib/sync-token.js b/src/lib/sync-token.js index 3eeef23..f00cb66 100644 --- a/src/lib/sync-token.js +++ b/src/lib/sync-token.js @@ -1,27 +1,13 @@ const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); const prisma = require('./prisma'); const SYNC_TOKEN_EXPIRES_IN = '5m'; -// 内存 Set 记录已消费的 sync_token jti,5 分钟后自动清理 -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,不存在则自动生成 async function getSyncSecret() { let config = await prisma.systemConfig.findUnique({ where: { key: 'SYNC_SECRET' } }); if (!config) { - const crypto = require('crypto'); const secret = crypto.randomBytes(32).toString('hex'); config = await prisma.systemConfig.create({ data: { key: 'SYNC_SECRET', value: secret }, @@ -30,38 +16,28 @@ async function getSyncSecret() { return config.value; } -// 生成 sync_token +// 生成 sync_token(5 分钟过期,jti 用 CSPRNG 生成) async function generateSyncToken(roleId, adminId) { 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 }); } // 验证 sync_token,返回 payload 或 null +// 注意:sync_token 在 5 分钟有效期内可多次使用(Hermes 需拉取 SOUL.md + config.yaml 两个文件) +// 重放防护依赖 5 分钟短过期 + HTTPS 传输 + roleId 绑定 async function verifySyncToken(token) { try { const secret = await getSyncSecret(); const decoded = jwt.verify(token, secret); if (decoded.type !== 'sync') return null; - - // 检查是否已消费 - if (consumedTokens.has(decoded.jti)) { - return null; - } - return decoded; } catch { return null; } } -// 标记 sync_token 为已消费 -function consumeSyncToken(jti) { - consumedTokens.set(jti, Date.now() + 5 * 60 * 1000); -} - module.exports = { generateSyncToken, verifySyncToken, - consumeSyncToken, }; diff --git a/src/routes/admin-config.js b/src/routes/admin-config.js index b161939..2a5bd91 100644 --- a/src/routes/admin-config.js +++ b/src/routes/admin-config.js @@ -7,6 +7,10 @@ const router = express.Router(); // 敏感配置项的值需要脱敏显示 const SENSITIVE_KEYS = ['SYNC_SECRET']; +// 受保护配置项:禁止通过 PUT 接口直接覆写 +// SYNC_SECRET 由系统自动生成并轮换,管理员手动覆写会导致已知密钥攻击 +const PROTECTED_KEYS = ['SYNC_SECRET']; + // 获取所有配置 router.get('/', adminAuthMiddleware, async (req, res) => { try { @@ -30,13 +34,29 @@ router.put('/:key', adminAuthMiddleware, async (req, res) => { if (value === undefined || value === null) { 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({ where: { key: req.params.key }, update: { 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) { console.error('更新系统配置失败:', err); res.status(500).json({ error: '更新失败' }); diff --git a/src/routes/admin-sync.js b/src/routes/admin-sync.js index 286cebe..0bd8f49 100644 --- a/src/routes/admin-sync.js +++ b/src/routes/admin-sync.js @@ -6,6 +6,56 @@ const { postSync } = require('../lib/hermes-client'); 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) => { try { @@ -41,8 +91,16 @@ router.post('/:roleId', adminAuthMiddleware, async (req, res) => { 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 回调拉取文件) - let baseUrl = req.protocol + '://' + req.get('host'); + // 优先使用环境变量 BASE_URL,避免 Host 头被伪造 + const baseUrl = process.env.BASE_URL || (req.protocol + '://' + req.get('host')); // 生成 sync_token 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 }); } catch (err) { - console.error('同步失败:', err.message); + console.error('同步失败:', err); - // 同步失败,更新状态 + // 同步失败,仅当当前状态为 syncing 时才回滚为 failed(避免误覆盖 approved) try { - await prisma.role.update({ + const current = await prisma.role.findUnique({ 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) { 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: '重置失败' }); } }); diff --git a/src/routes/admin.js b/src/routes/admin.js index a84d835..e7209ae 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -7,15 +7,21 @@ const router = express.Router(); // 获取待审核列表 router.get('/reviews', adminAuthMiddleware, async (req, res) => { 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 [roles, total] = await Promise.all([ prisma.role.findMany({ where, orderBy: { createdAt: 'desc' }, - skip: (Number(page) - 1) * Number(pageSize), - take: Number(pageSize), + skip: (page - 1) * pageSize, + take: pageSize, select: { id: true, displayName: true, @@ -30,7 +36,7 @@ router.get('/reviews', adminAuthMiddleware, async (req, res) => { prisma.role.count({ where }), ]); - res.json({ roles, total, page: Number(page), pageSize: Number(pageSize) }); + res.json({ roles, total, page, pageSize }); } catch (err) { console.error('获取审核列表失败:', err); res.status(500).json({ error: '服务器错误' }); diff --git a/src/routes/hermes.js b/src/routes/hermes.js index 6db3031..4b2f50e 100644 --- a/src/routes/hermes.js +++ b/src/routes/hermes.js @@ -61,7 +61,7 @@ async function hermesAuthMiddleware(req, res, next) { select: { creatorId: true }, }); if (!role) { - return res.status(404).json({ error: 'Role not found' }); + return res.status(404).json({ error: '角色不存在' }); } req.userId = role.creatorId; 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 }, }); if (!role) { - return res.status(404).json({ error: 'Role not found' }); + return res.status(404).json({ error: '角色不存在' }); } 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) { - 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); } catch (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) { - return res.status(404).json({ error: 'Role not found' }); + return res.status(404).json({ error: '角色不存在' }); } 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); res.type('text/plain').send(config); } catch (err) { console.error('获取 config.yaml 失败:', err); - res.status(500).json({ error: 'Server error' }); + res.status(500).json({ error: '服务器错误' }); } }); diff --git a/src/routes/roles.js b/src/routes/roles.js index 64bb47f..bf815e1 100644 --- a/src/routes/roles.js +++ b/src/routes/roles.js @@ -26,7 +26,7 @@ router.get('/', async (req, res) => { } }); -// 获取角色详情 +// 获取角色详情(仅显示已同步完成的角色) router.get('/:id', async (req, res) => { try { const role = await prisma.role.findUnique({ @@ -46,11 +46,16 @@ router.get('/:id', async (req, res) => { speechStyle: true, greeting: true, creatorId: true, + reviewStatus: true, }, }); if (!role) { return res.status(404).json({ error: '角色不存在' }); } + // 角色库仅展示已同步的角色 + if (role.reviewStatus !== 'synced') { + return res.status(404).json({ error: '角色不存在' }); + } res.json({ role }); } catch (err) { console.error('获取角色详情失败:', err); @@ -144,6 +149,9 @@ router.put('/:id', authMiddleware, async (req, res) => { } const data = req.body; + // 若编辑已同步(synced)的角色,重置为 pending_review,需重新审核 + 同步 + // 防止 Hermes 端的 profile 与 EternalAI 端的角色数据不一致 + const shouldResetReview = existing.reviewStatus === 'synced'; const role = await prisma.role.update({ where: { id: req.params.id }, data: { @@ -171,10 +179,19 @@ router.put('/:id', authMiddleware, async (req, res) => { desc: data.desc ?? existing.desc, price: parseFloat(data.price) || existing.price, 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) { console.error('编辑角色失败:', err); res.status(500).json({ error: '编辑失败' });