fix(security): 补齐审计日志脱敏缺口 + step-up 下载/取消体验修复

审计发现修复:

高危——审计日志请求体脱敏不全(audit_logs 沦为明文凭证聚合点):
- 键名归一化比对(小写+去分隔符),覆盖 privateKey/apiv3key 等无分隔符与 camelCase 写法
- 程序化并入 SensitiveCredentialKeys 与 providerSensitiveConfigFields 两份权威敏感表,防清单漂移
- 补齐 proxy_key(内嵌代理密码)、custom_key(自设 API Key 明文)精确键
- Codex session 导入路由 body 整体由粘贴的 auth JSON 构成,键级脱敏无法覆盖,整体不入库
- 新增守卫测试:两份权威表的每个键必须被审计脱敏命中;provider_key 等渠道标识保留以便追责

中危——step-up 前端体验:
- 备份下载改同页 anchor 导航(预签名 URL 后端强制 attachment disposition),
  避免 step-up 弹窗 await 耗尽瞬态激活后 window.open 被浏览器拦截
- useStepUp.run 用户取消时抛 StepUpCancelledError sentinel,
  三个调用点静默处理,不再把取消误报为红色错误 toast

低危:
- 修正审计中间件挂载位置的陈旧注释(实际挂在认证之后)
- 审计 body 捕获改 LimitReader 按 256KB 上限截断读取,超出部分拼接回填,
  避免大体积导入请求被完整复制进内存两次
- TOTP step-up 弹窗验证成功后即时清空验证码输入
This commit is contained in:
shaw
2026-07-16 14:38:16 +08:00
parent a1af031969
commit 2de6ccb071
9 changed files with 205 additions and 28 deletions
@@ -111,6 +111,7 @@ async function submit(otp: string) {
try {
await totpAPI.stepUp(otp)
verifying.value = false
resetInputs()
props.controller.onVerified()
} catch (err: any) {
verifying.value = false
@@ -1,5 +1,12 @@
import { describe, it, expect, vi } from 'vitest'
import { useStepUp, isStepUpRequired, isStepUpBlocked, stepUpBlockReason } from '../useStepUp'
import {
useStepUp,
isStepUpRequired,
isStepUpBlocked,
isStepUpCancelled,
stepUpBlockReason,
StepUpCancelledError
} from '../useStepUp'
describe('useStepUp error classification', () => {
it('detects STEP_UP_REQUIRED from code field', () => {
@@ -60,12 +67,21 @@ describe('useStepUp.run', () => {
expect(stepUp.visible.value).toBe(false)
})
it('re-throws the original error if the user cancels the prompt', async () => {
it('throws a cancellation sentinel (not the original error) if the user cancels the prompt', async () => {
const stepUp = useStepUp()
const err = { status: 403, code: 'STEP_UP_REQUIRED' }
const promise = stepUp.run(async () => { throw err })
await vi.waitFor(() => expect(stepUp.visible.value).toBe(true))
stepUp.onCancel()
await expect(promise).rejects.toBe(err)
await expect(promise).rejects.toBeInstanceOf(StepUpCancelledError)
expect(stepUp.visible.value).toBe(false)
})
it('classifies the cancellation sentinel distinctly from step-up errors', () => {
const cancelled = new StepUpCancelledError()
expect(isStepUpCancelled(cancelled)).toBe(true)
expect(isStepUpRequired(cancelled)).toBe(false)
expect(isStepUpBlocked(cancelled)).toBe(false)
expect(isStepUpCancelled({ status: 403, code: 'STEP_UP_REQUIRED' })).toBe(false)
})
})
+20 -2
View File
@@ -19,6 +19,22 @@ const STEP_UP_REQUIRED = 'STEP_UP_REQUIRED'
const STEP_UP_TOTP_NOT_ENABLED = 'STEP_UP_TOTP_NOT_ENABLED'
const STEP_UP_ADMIN_API_KEY_FORBIDDEN = 'STEP_UP_ADMIN_API_KEY_FORBIDDEN'
/**
* Thrown by run() when the user dismisses the TOTP dialog.
* Callers should treat it as a silent no-op, not an error to toast.
*/
export class StepUpCancelledError extends Error {
readonly code = 'STEP_UP_CANCELLED'
constructor() {
super('step-up verification cancelled by user')
this.name = 'StepUpCancelledError'
}
}
export function isStepUpCancelled(err: unknown): boolean {
return err instanceof StepUpCancelledError
}
interface ApiError {
status?: number
code?: string | number
@@ -76,7 +92,9 @@ export function useStepUp() {
/**
* Run a sensitive action. On STEP_UP_REQUIRED, prompt for a TOTP code and
* retry once. STEP_UP_TOTP_NOT_ENABLED / admin-api-key errors are surfaced
* to the caller (they cannot be resolved by entering a code).
* to the caller (they cannot be resolved by entering a code). If the user
* cancels the prompt, a StepUpCancelledError is thrown so callers can
* distinguish "user changed their mind" from real failures.
*/
async function run<T>(action: () => Promise<T>): Promise<T> {
try {
@@ -91,7 +109,7 @@ export function useStepUp() {
}
const ok = await prompt()
if (!ok) {
throw err
throw new StepUpCancelledError()
}
// Retry once now that the session holds a step-up grant.
return await action()
+4 -2
View File
@@ -489,7 +489,7 @@ import { adminAPI } from '@/api/admin'
import { useTableLoader } from '@/composables/useTableLoader'
import { useSwipeSelect, type SwipeSelectVirtualContext } from '@/composables/useSwipeSelect'
import { useTableSelection } from '@/composables/useTableSelection'
import { useStepUp, isStepUpBlocked, stepUpBlockReason } from '@/composables/useStepUp'
import { useStepUp, isStepUpBlocked, isStepUpCancelled, stepUpBlockReason } from '@/composables/useStepUp'
import TotpStepUpDialog from '@/components/auth/TotpStepUpDialog.vue'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
@@ -1825,7 +1825,9 @@ const handleExportData = async () => {
appStore.showSuccess(t('admin.accounts.dataExported'))
}
} catch (error: any) {
if (isStepUpBlocked(error)) {
if (isStepUpCancelled(error)) {
// 用户主动取消 step-up 验证,静默返回,不弹错误提示。
} else if (isStepUpBlocked(error)) {
appStore.showError(
stepUpBlockReason(error) === 'STEP_UP_ADMIN_API_KEY_FORBIDDEN'
? t('stepUp.adminApiKeyForbidden')
+12 -2
View File
@@ -285,7 +285,7 @@ import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api'
import { useAppStore } from '@/stores'
import type { BackupS3Config, BackupScheduleConfig, BackupRecord } from '@/api/admin/backup'
import { useStepUp, isStepUpBlocked, stepUpBlockReason } from '@/composables/useStepUp'
import { useStepUp, isStepUpBlocked, isStepUpCancelled, stepUpBlockReason } from '@/composables/useStepUp'
import TotpStepUpDialog from '@/components/auth/TotpStepUpDialog.vue'
const { t } = useI18n()
@@ -543,6 +543,10 @@ async function createBackup() {
backups.value.unshift(record)
startPolling(record.id)
} catch (error: any) {
if (isStepUpCancelled(error)) {
creatingBackup.value = false
return
}
if (reportStepUpBlocked(error)) {
creatingBackup.value = false
return
@@ -559,8 +563,14 @@ async function createBackup() {
async function downloadBackup(id: string) {
try {
const result = await backupStepUp.run(() => adminAPI.backup.getDownloadURL(id))
window.open(result.url, '_blank')
// 预签名 URL 带 attachment disposition,同页 anchor 导航直接触发下载;
// 不用 window.open:step-up 弹窗 await 会耗尽瞬态用户激活,新标签页会被浏览器拦截。
const link = document.createElement('a')
link.href = result.url
link.rel = 'noopener'
link.click()
} catch (error) {
if (isStepUpCancelled(error)) return
if (reportStepUpBlocked(error)) return
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
}