mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 16:23:31 +00:00
fix(security-audit): 解密失败不再吞掉整份配置,修复升级后配置消失且无法保存的死锁 (#4887)
根因:prompt audit 是共享 TOTP_ENCRYPTION_KEY 加密器的功能中唯一不校验 EncryptionKeyConfigured 的落点。未配置固定密钥的部署每次重启自动生成新 密钥,v162 保存的节点 Token 密文在升级重启后永久无法解密,Reload 中 ActiveFromStorage 整体失败导致快照永远装不上:管理端 GET 回退默认 v1 (v166 起为 503),而保存路径直读数据库做 CAS 版本对比,必然冲突—— 配置既看不见也改不掉。PR #4893 仅改变了报错形态,未修复根因。 修复: - ActiveFromStorage 对单节点解密失败降级容忍:该节点运行时禁用并标记 TokenInvalid,配置整体照常激活;管理端恢复显示真实版本号,重新输入 Token 即可自愈(密文保留,密钥恢复后自动复原) - blocking 意图下零可用节点时 evaluator 仍返回 unavailable,请求照旧 被拒,fail-closed 语义不回归;async 意图下 enqueue 直接 drop 并告警 - Save 在未配置固定加密密钥时拒绝保存新 Token(与 TOTP/Ollama/备份 一致的门控),错误码 prompt_audit_encryption_key_required - token_status 新增 invalid 状态,前端凭据列与编辑框提示重新输入 - 新增 config_token_invalid 告警日志(集合变化时记录一次,不随 5s 刷新刷屏)
This commit is contained in:
@@ -50,6 +50,21 @@ describe('Prompt Audit components', () => {
|
||||
expect(wrapper.emitted('probe')?.[0]?.[0]).toMatchObject({ id: 'guard-1' })
|
||||
})
|
||||
|
||||
it('surfaces an undecryptable saved credential and prompts for re-entry', async () => {
|
||||
const invalidEndpoint = { ...endpoint(), token_status: 'invalid' }
|
||||
const wrapper = mount(EndpointPool, {
|
||||
props: { endpoints: [invalidEndpoint], probeResults: {}, probingIds: [] },
|
||||
global: { stubs: { BaseDialog: DialogStub } },
|
||||
})
|
||||
expect(wrapper.text()).toContain('admin.promptAudit.pool.invalid')
|
||||
expect(wrapper.text()).not.toContain('admin.promptAudit.pool.configured')
|
||||
|
||||
const edit = wrapper.findAll('button').find((button) => button.text().includes('common.edit'))
|
||||
await edit!.trigger('click')
|
||||
const token = wrapper.get<HTMLInputElement>('[aria-label="admin.promptAudit.pool.apiKey"]')
|
||||
expect(token.attributes('placeholder')).toContain('admin.promptAudit.pool.reenterSecret')
|
||||
})
|
||||
|
||||
it('supports group search, stale configured groups, nine scanners, and bounded worker inputs', async () => {
|
||||
const draft: PromptAuditDraft = {
|
||||
enabled: true, blocking_enabled: false, store_pass_events: false, effective_mode: 'async_audit', strategy: 'priority',
|
||||
|
||||
@@ -68,9 +68,9 @@
|
||||
|
||||
<div class="min-w-0">
|
||||
<p class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 xl:hidden">{{ t('admin.promptAudit.pool.credential') }}</p>
|
||||
<div class="flex items-center gap-1.5 text-xs font-medium" :class="hasCredential(endpoint) ? 'text-emerald-700 dark:text-emerald-300' : 'text-gray-500 dark:text-dark-400'">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="hasCredential(endpoint) ? 'bg-emerald-500' : 'bg-gray-300 dark:bg-dark-500'" aria-hidden="true" />
|
||||
{{ hasCredential(endpoint) ? t('admin.promptAudit.pool.configured') : t('admin.promptAudit.pool.missing') }}
|
||||
<div class="flex items-center gap-1.5 text-xs font-medium" :class="credentialInvalid(endpoint) ? 'text-red-600 dark:text-red-300' : hasCredential(endpoint) ? 'text-emerald-700 dark:text-emerald-300' : 'text-gray-500 dark:text-dark-400'">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="credentialInvalid(endpoint) ? 'bg-red-500' : hasCredential(endpoint) ? 'bg-emerald-500' : 'bg-gray-300 dark:bg-dark-500'" aria-hidden="true" />
|
||||
{{ credentialInvalid(endpoint) ? t('admin.promptAudit.pool.invalid') : hasCredential(endpoint) ? t('admin.promptAudit.pool.configured') : t('admin.promptAudit.pool.missing') }}
|
||||
</div>
|
||||
<p v-if="probingIds.includes(endpoint.id)" class="mt-1.5 text-xs text-primary-600 dark:text-primary-300">
|
||||
{{ t('admin.promptAudit.pool.probeProgress') }}
|
||||
@@ -108,7 +108,7 @@
|
||||
</label>
|
||||
<label class="space-y-1 text-sm text-gray-700 dark:text-dark-200 sm:col-span-2">
|
||||
<span>{{ t('admin.promptAudit.pool.apiKey') }}</span>
|
||||
<input v-model="editing.token" class="input w-full" type="password" autocomplete="new-password" :placeholder="editing.has_token ? t('admin.promptAudit.pool.keepSecret') : ''" :aria-label="t('admin.promptAudit.pool.apiKey')" />
|
||||
<input v-model="editing.token" class="input w-full" type="password" autocomplete="new-password" :placeholder="editing.has_token ? (editing.token_status === 'invalid' ? t('admin.promptAudit.pool.reenterSecret') : t('admin.promptAudit.pool.keepSecret')) : ''" :aria-label="t('admin.promptAudit.pool.apiKey')" />
|
||||
<span class="block text-xs text-gray-500 dark:text-dark-400">{{ t('admin.promptAudit.pool.secretHint') }}</span>
|
||||
</label>
|
||||
<label v-if="editing.has_token" class="flex items-center gap-2 text-sm text-red-600 dark:text-red-300 sm:col-span-2">
|
||||
@@ -190,4 +190,7 @@ function removeEndpoint(endpoint: PromptAuditEndpointDraft) {
|
||||
function hasCredential(endpoint: PromptAuditEndpointDraft): boolean {
|
||||
return Boolean(endpoint.token.trim() || (endpoint.has_token && !endpoint.clear_token))
|
||||
}
|
||||
function credentialInvalid(endpoint: PromptAuditEndpointDraft): boolean {
|
||||
return endpoint.token_status === 'invalid' && !endpoint.token.trim() && !endpoint.clear_token
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface PromptAuditEndpoint {
|
||||
input_limit: number
|
||||
enabled: boolean
|
||||
has_token: boolean
|
||||
token_status: 'configured' | 'missing' | string
|
||||
token_status: 'configured' | 'missing' | 'invalid' | string
|
||||
}
|
||||
|
||||
export interface PromptAuditEndpointDraft extends PromptAuditEndpoint {
|
||||
|
||||
@@ -44,9 +44,9 @@ export default {
|
||||
pool: {
|
||||
title: 'Audit pool', description: 'Enabled OpenAI-compatible nodes are tried in order. Probes run from the server network.',
|
||||
add: 'Add node', edit: 'Edit node', empty: 'No audit nodes configured.', node: 'Node', model: 'Model', limits: 'Timeout / chunk limit', credential: 'Credential and probe',
|
||||
configured: 'API Key configured', missing: 'API Key missing', probe: 'Test connection', probing: 'Probing…',
|
||||
configured: 'API Key configured', missing: 'API Key missing', invalid: 'API Key cannot be decrypted; re-enter it', probe: 'Test connection', probing: 'Probing…',
|
||||
probeProgress: 'Config validated ✓ · request sent · awaiting service response…', probeResult: 'Config ✓ · request ✓ · HTTP {http} · {status} · {latency} ms',
|
||||
name: 'Node name', id: 'Stable node ID', baseUrl: 'Base URL', apiKey: 'API Key', keepSecret: 'Leave blank to keep the saved API Key',
|
||||
name: 'Node name', id: 'Stable node ID', baseUrl: 'Base URL', apiKey: 'API Key', keepSecret: 'Leave blank to keep the saved API Key', reenterSecret: 'The saved API Key cannot be decrypted (encryption key changed); enter a new one',
|
||||
secretHint: 'Plaintext exists only in this editor and is cleared immediately after a successful save.', clearSecret: 'Explicitly clear the saved API Key', timeout: 'Total timeout (ms)', inputLimit: 'Unicode characters per chunk',
|
||||
toggleNode: 'Toggle node {name}', deleteConfirm: 'Remove “{name}” from the draft? It takes effect after saving.',
|
||||
},
|
||||
@@ -93,6 +93,7 @@ export default {
|
||||
errors: {
|
||||
loadConfig: 'Unable to load Prompt Audit configuration.', loadRuntime: 'Unable to load Prompt Audit runtime.', loadGroups: 'Unable to load groups.', loadEvents: 'Unable to load audit events.', loadDetail: 'Unable to load event details.', saveConfig: 'Unable to save the configuration.', probe: 'Node probe failed.', delete: 'Unable to delete events.', previewDelete: 'Unable to create a deletion preview. Check the time range.', deleteConfirmation: 'The deletion confirmation is invalid or expired. Preview again.',
|
||||
prompt_audit_config_conflict: 'Another administrator updated this configuration. Reload the server version before deciding how to merge your draft.',
|
||||
prompt_audit_encryption_key_required: 'No fixed encryption key is configured, so audit node API Keys would be lost on restart. Set the TOTP_ENCRYPTION_KEY environment variable and restart the service first.',
|
||||
prompt_guard_requires_audit_enabled: 'Enable Prompt Audit before synchronous blocking.', prompt_audit_invalid_endpoint: 'The audit node configuration is invalid.', prompt_audit_endpoint_required: 'Enable at least one audit node before enabling Prompt Audit.', prompt_audit_groups_required: 'Select at least one group in selected-group mode.', prompt_audit_scanners_required: 'Enable at least one risk category.',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -44,9 +44,9 @@ export default {
|
||||
pool: {
|
||||
title: '审计池', description: '按顺序使用启用的 OpenAI 兼容节点;探测由服务端真实网络环境发起。',
|
||||
add: '新增节点', edit: '编辑节点', empty: '尚未配置审计节点。', node: '节点', model: '模型', limits: '超时 / 单片上限', credential: '凭据与探测',
|
||||
configured: 'API Key 已配置', missing: '未配置 API Key', probe: '连接测试', probing: '探测中…',
|
||||
configured: 'API Key 已配置', missing: '未配置 API Key', invalid: 'API Key 无法解密,请重新输入', probe: '连接测试', probing: '探测中…',
|
||||
probeProgress: '配置校验 ✓ · 请求已发送 · 等待服务响应…', probeResult: '配置校验 ✓ · 请求 ✓ · HTTP {http} · {status} · {latency} ms',
|
||||
name: '节点名称', id: '稳定节点 ID', baseUrl: 'Base URL', apiKey: 'API Key', keepSecret: '留空以保留已保存的 API Key',
|
||||
name: '节点名称', id: '稳定节点 ID', baseUrl: 'Base URL', apiKey: 'API Key', keepSecret: '留空以保留已保存的 API Key', reenterSecret: '已保存的 API Key 无法解密(加密密钥已变更),请重新输入',
|
||||
secretHint: '明文只在本次编辑内存中存在;保存成功后会立即清除。', clearSecret: '显式清除已保存的 API Key', timeout: '总超时(毫秒)', inputLimit: '单片 Unicode 字符上限',
|
||||
toggleNode: '切换节点 {name}', deleteConfirm: '从草稿中删除节点“{name}”?保存配置后生效。',
|
||||
},
|
||||
@@ -93,6 +93,7 @@ export default {
|
||||
errors: {
|
||||
loadConfig: '无法加载提示词审计配置。', loadRuntime: '无法加载提示词审计运行态。', loadGroups: '无法加载分组列表。', loadEvents: '无法加载审计事件。', loadDetail: '无法加载事件详情。', saveConfig: '配置保存失败。', probe: '节点探测失败。', delete: '事件删除失败。', previewDelete: '无法生成删除预览,请检查时间范围。', deleteConfirmation: '删除确认无效或已过期,请重新预览。',
|
||||
prompt_audit_config_conflict: '配置已被其他管理员更新。请重新加载服务端配置,再决定如何合并本地草稿。',
|
||||
prompt_audit_encryption_key_required: '未配置固定加密密钥,审计节点 API Key 将在服务重启后失效。请先设置 TOTP_ENCRYPTION_KEY 环境变量并重启服务。',
|
||||
prompt_guard_requires_audit_enabled: '开启同步阻止前必须先启用提示词审计。', prompt_audit_invalid_endpoint: '审计节点配置无效。', prompt_audit_endpoint_required: '启用审计前至少需要一个启用节点。', prompt_audit_groups_required: '指定分组模式至少需要选择一个分组。', prompt_audit_scanners_required: '至少需要启用一个风险分类。',
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user