mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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 = "<credential-bearing body omitted>"
|
||||
} 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 != "" {
|
||||
|
||||
@@ -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 "<body omitted: " + strconv.Itoa(len(raw)) + " bytes>"
|
||||
if len(raw) > AuditRequestBodyCaptureLimit {
|
||||
// raw 可能已被中间件按上限截断,实际请求体只会更大,不报具体字节数。
|
||||
return "<body omitted: exceeds " + strconv.Itoa(AuditRequestBodyCaptureLimit) + " bytes>"
|
||||
}
|
||||
ct := strings.ToLower(contentType)
|
||||
if !strings.Contains(ct, "json") || !json.Valid(raw) {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user