diff --git a/backend/internal/repository/backup_s3_store.go b/backend/internal/repository/backup_s3_store.go index 2104e1e5d7..b61586a0b0 100644 --- a/backend/internal/repository/backup_s3_store.go +++ b/backend/internal/repository/backup_s3_store.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "path" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -103,9 +104,13 @@ func (s *S3BackupStore) Delete(ctx context.Context, key string) error { func (s *S3BackupStore) PresignURL(ctx context.Context, key string, expiry time.Duration) (string, error) { presignClient := s3.NewPresignClient(s.client) + // 强制 attachment disposition:浏览器同页导航该 URL 时直接触发下载而非渲染, + // 前端无需依赖会被弹窗拦截的新标签页。 + disposition := fmt.Sprintf("attachment; filename=%q", path.Base(key)) result, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{ - Bucket: &s.bucket, - Key: &key, + Bucket: &s.bucket, + Key: &key, + ResponseContentDisposition: &disposition, }, s3.WithPresignExpires(expiry)) if err != nil { return "", fmt.Errorf("presign url: %w", err) diff --git a/backend/internal/server/middleware/audit_log.go b/backend/internal/server/middleware/audit_log.go index 6541e841a4..1cb510f06b 100644 --- a/backend/internal/server/middleware/audit_log.go +++ b/backend/internal/server/middleware/audit_log.go @@ -78,9 +78,16 @@ var auditActionOverrides = map[string]string{ "DELETE /api/v1/admin/settings/admin-api-key": "admin.admin_api_key.delete", } +// auditBodyOmittedRoutes 请求体几乎整体由凭证构成的路由(如整块粘贴 auth JSON 的导入接口)。 +// 这类 body 的凭证内嵌在普通字符串值里,键级脱敏无法覆盖,整体不入库。 +var auditBodyOmittedRoutes = map[string]struct{}{ + "POST /api/v1/admin/accounts/import/codex-session": {}, +} + // NewAuditLogMiddleware 创建审计中间件。 // 记录范围:变更类请求(POST/PUT/PATCH/DELETE)+ 白名单内的敏感 GET 读取。 -// 该中间件应挂在认证中间件之前,以便同时记录认证失败的尝试。 +// 挂载位置:admin / user / admin-payment 组挂在各自认证中间件之后(只审计已认证请求, +// 未过认证的 401/403 不入库);auth 组(登录/注册/刷新)无前置认证,天然记录失败尝试。 func NewAuditLogMiddleware(auditService *service.AuditLogService) AuditLogMiddleware { return AuditLogMiddleware(func(c *gin.Context) { routeKey := c.Request.Method + " " + c.FullPath() @@ -105,11 +112,19 @@ func NewAuditLogMiddleware(auditService *service.AuditLogService) AuditLogMiddle } // 捕获请求体(读出后回填,避免影响后续 ShouldBindJSON)。 + // 只读取脱敏解析上限内的字节,超出部分与已读部分拼接回填, + // 避免大体积导入请求被完整复制进内存两次。 var bodyRedacted string - if c.Request.Body != nil && c.Request.Method != "GET" { - raw, err := io.ReadAll(c.Request.Body) + if _, omit := auditBodyOmittedRoutes[routeKey]; omit { + bodyRedacted = "" + } else if c.Request.Body != nil && c.Request.Method != "GET" { + orig := c.Request.Body + raw, err := io.ReadAll(io.LimitReader(orig, service.AuditRequestBodyCaptureLimit+1)) if err == nil { - c.Request.Body = io.NopCloser(bytes.NewReader(raw)) + c.Request.Body = &restoredBody{ + Reader: io.MultiReader(bytes.NewReader(raw), orig), + closer: orig, + } bodyRedacted = service.RedactAuditBody(raw, c.GetHeader("Content-Type")) } } @@ -199,6 +214,15 @@ func NewAuditLogMiddleware(auditService *service.AuditLogService) AuditLogMiddle }) } +// restoredBody 把审计中间件按上限读出的前缀与未读完的原始 body 拼接回填, +// 保证 handler 读到完整请求体;Close 委托给原始 body。 +type restoredBody struct { + io.Reader + closer io.Closer +} + +func (b *restoredBody) Close() error { return b.closer.Close() } + // MaskedRequestCredential 提取请求头中的凭证并做首尾掩码。 func MaskedRequestCredential(c *gin.Context) string { if apiKey := strings.TrimSpace(c.GetHeader("x-api-key")); apiKey != "" { diff --git a/backend/internal/service/audit_log.go b/backend/internal/service/audit_log.go index 08d2b6cb20..aabb0ca4b6 100644 --- a/backend/internal/service/audit_log.go +++ b/backend/internal/service/audit_log.go @@ -22,8 +22,9 @@ const ( // auditRequestBodyMaxBytes 请求体脱敏后入库的最大长度(字节),超出截断。 auditRequestBodyMaxBytes = 16 * 1024 - // auditRequestBodyCaptureLimit 请求体参与脱敏解析的原始大小上限,超出不解析仅记录占位符。 - auditRequestBodyCaptureLimit = 256 * 1024 + // AuditRequestBodyCaptureLimit 请求体参与脱敏解析的原始大小上限(字节)。 + // 审计中间件按此上限截断读取,超出的请求体仅记录占位符不解析。 + AuditRequestBodyCaptureLimit = 256 * 1024 ) // 内置审计动作名(认证/安全事件与特殊操作使用固定值,普通请求由路由自动推导)。 @@ -100,23 +101,63 @@ type AuditLogRepository interface { DeleteBefore(ctx context.Context, cutoff time.Time, batchSize int) (int64, error) } -// auditBodySensitiveExactKeys 请求体脱敏的精确匹配键(小写)。 -var auditBodySensitiveExactKeys = map[string]struct{}{ - "code": {}, "codes": {}, "pin": {}, "cvv": {}, - "authorization": {}, "cookie": {}, "x-api-key": {}, - "key": {}, +// auditNormalizeBodyKey 归一化键名:小写并去除分隔符, +// 使 private_key / privateKey / privatekey / api-v3-key 等写法共享同一判定, +// 避免子串清单假设 snake_case 而漏掉支付渠道等无分隔符风格的密钥字段。 +func auditNormalizeBodyKey(key string) string { + var b strings.Builder + b.Grow(len(key)) + for _, r := range strings.ToLower(strings.TrimSpace(key)) { + switch r { + case '_', '-', '.', ' ': + continue + default: + b.WriteRune(r) + } + } + return b.String() } -// auditBodySensitiveSubstrings 请求体脱敏的包含匹配子串(小写)。 +// auditBodySensitiveExactKeys 请求体脱敏的精确匹配键(归一化后)。 +// 除内置清单外,程序化并入两份权威敏感表以防清单漂移: +// - SensitiveCredentialKeys:账号 credentials 的敏感子键(session_key / service_account_json 等) +// - providerSensitiveConfigFields:支付渠道密钥字段(pkey / privatekey / apiv3key 等) +var auditBodySensitiveExactKeys = func() map[string]struct{} { + builtin := []string{ + "code", "codes", "pin", "cvv", + "authorization", "cookie", "x-api-key", + "key", + // 字符串值内嵌完整凭证的字段: + // proxy_key 为 protocol|host|port|username|password 拼接, + // custom_key 为用户自设的平台 API Key 明文。 + "proxy_key", "custom_key", + } + set := make(map[string]struct{}, len(builtin)+len(SensitiveCredentialKeys)+16) + for _, k := range builtin { + set[auditNormalizeBodyKey(k)] = struct{}{} + } + for _, k := range SensitiveCredentialKeys { + set[auditNormalizeBodyKey(k)] = struct{}{} + } + for _, fields := range providerSensitiveConfigFields { + for k := range fields { + set[auditNormalizeBodyKey(k)] = struct{}{} + } + } + return set +}() + +// auditBodySensitiveSubstrings 请求体脱敏的包含匹配子串(对归一化后的键名比对)。 // 命中任一子串即整体擦除该键的值(例如 new_password / secret_access_key / temp_token)。 var auditBodySensitiveSubstrings = []string{ "password", "passwd", "secret", "token", - "api_key", "apikey", "access_key", "private_key", - "otp", "totp", "credential_value", + "apikey", "accesskey", "privatekey", + "otp", "credentialvalue", + "sessionkey", "serviceaccount", } func isAuditSensitiveBodyKey(key string) bool { - k := strings.ToLower(strings.TrimSpace(key)) + k := auditNormalizeBodyKey(key) if _, ok := auditBodySensitiveExactKeys[k]; ok { return true } @@ -138,8 +179,9 @@ func RedactAuditBody(raw []byte, contentType string) string { if len(raw) == 0 { return "" } - if len(raw) > auditRequestBodyCaptureLimit { - return "" + if len(raw) > AuditRequestBodyCaptureLimit { + // raw 可能已被中间件按上限截断,实际请求体只会更大,不报具体字节数。 + return "" } ct := strings.ToLower(contentType) if !strings.Contains(ct, "json") || !json.Valid(raw) { diff --git a/backend/internal/service/audit_log_test.go b/backend/internal/service/audit_log_test.go index b5fe97fdf0..ad4b4b8805 100644 --- a/backend/internal/service/audit_log_test.go +++ b/backend/internal/service/audit_log_test.go @@ -62,6 +62,65 @@ func TestRedactAuditBody_JSONRedactsSecrets(t *testing.T) { } } +// TestRedactAuditBody_AuthoritativeTablesSynced 覆盖曾经漏网的凭证字段: +// 账号 credentials 敏感子键、支付渠道无分隔符密钥、字符串值内嵌凭证的 proxy_key / custom_key, +// 以及 camelCase 等命名变体(归一化比对)。 +func TestRedactAuditBody_AuthoritativeTablesSynced(t *testing.T) { + raw := []byte(`{ + "credentials": { + "session_key": "sk-session-aaa", + "service_account_json": "{\"private_key\":\"pem-body-bbb\"}", + "service_account": "sa-blob-ccc" + }, + "proxy_key": "socks5|1.2.3.4|1080|proxyuser|proxypass-ddd", + "custom_key": "sk-custom-eee", + "config": { + "pkey": "easypay-merchant-fff", + "privateKey": "alipay-pem-ggg", + "apiv3key": "wxpay-v3-hhh", + "SecretKey": "stripe-sk-iii", + "webhookSecret": "whsec-jjj" + }, + "provider_key": "stripe", + "name": "instance-1" + }`) + out := RedactAuditBody(raw, "application/json") + + for _, secret := range []string{ + "sk-session-aaa", "pem-body-bbb", "sa-blob-ccc", + "proxypass-ddd", "sk-custom-eee", + "easypay-merchant-fff", "alipay-pem-ggg", "wxpay-v3-hhh", + "stripe-sk-iii", "whsec-jjj", + } { + if strings.Contains(out, secret) { + t.Fatalf("redacted body still contains secret %q: %s", secret, out) + } + } + // provider_key 是渠道标识而非密钥,必须保留以便追责。 + if !strings.Contains(out, `"provider_key":"stripe"`) { + t.Fatalf("provider_key should be preserved for accountability: %s", out) + } + if !strings.Contains(out, "instance-1") { + t.Fatalf("name should be preserved: %s", out) + } +} + +// SensitiveCredentialKeys 中的每个键都必须被审计脱敏判定命中(防两表漂移的守卫)。 +func TestAuditSensitiveKeys_CoverCredentialTable(t *testing.T) { + for _, k := range SensitiveCredentialKeys { + if !isAuditSensitiveBodyKey(k) { + t.Fatalf("credential key %q is not covered by audit redaction", k) + } + } + for provider, fields := range providerSensitiveConfigFields { + for k := range fields { + if !isAuditSensitiveBodyKey(k) { + t.Fatalf("payment provider %q sensitive field %q is not covered by audit redaction", provider, k) + } + } + } +} + func TestRedactAuditBody_NonJSONOmitted(t *testing.T) { out := RedactAuditBody([]byte("username=admin&password=secret"), "application/x-www-form-urlencoded") if strings.Contains(out, "secret") { diff --git a/frontend/src/components/auth/TotpStepUpDialog.vue b/frontend/src/components/auth/TotpStepUpDialog.vue index 9bfdfb0212..3051895b7a 100644 --- a/frontend/src/components/auth/TotpStepUpDialog.vue +++ b/frontend/src/components/auth/TotpStepUpDialog.vue @@ -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 diff --git a/frontend/src/composables/__tests__/useStepUp.spec.ts b/frontend/src/composables/__tests__/useStepUp.spec.ts index 1afbdb0133..df0482879a 100644 --- a/frontend/src/composables/__tests__/useStepUp.spec.ts +++ b/frontend/src/composables/__tests__/useStepUp.spec.ts @@ -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) }) }) diff --git a/frontend/src/composables/useStepUp.ts b/frontend/src/composables/useStepUp.ts index ba6123cd18..77ae51788f 100644 --- a/frontend/src/composables/useStepUp.ts +++ b/frontend/src/composables/useStepUp.ts @@ -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(action: () => Promise): Promise { 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() diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index c784d82c17..7b3e1c3f64 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -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') diff --git a/frontend/src/views/admin/BackupView.vue b/frontend/src/views/admin/BackupView.vue index 4a2a3cd83a..684d827946 100644 --- a/frontend/src/views/admin/BackupView.vue +++ b/frontend/src/views/admin/BackupView.vue @@ -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')) }