From f1ba80517347aa9a60c60809915938aa97455edf Mon Sep 17 00:00:00 2001 From: CJACK Date: Sun, 26 Apr 2026 00:10:59 +0800 Subject: [PATCH] fix: fully mask web secret previews --- internal/admin/handler_accounts_crud.go | 10 +--- internal/admin/handler_accounts_crud_test.go | 30 +++++++++++ internal/admin/handler_config_read.go | 10 +--- internal/admin/handler_test.go | 50 +++++++++++++++++++ internal/admin/helpers.go | 11 ++++ .../testsuite/edge_cases_error_contract.go | 8 ++- webui/src/features/account/AddKeyModal.jsx | 5 +- webui/src/features/account/ApiKeysPanel.jsx | 4 +- webui/src/features/apiTester/ConfigPanel.jsx | 5 +- webui/src/locales/en.json | 2 +- webui/src/locales/zh.json | 2 +- webui/src/utils/maskSecret.js | 10 ++++ 12 files changed, 123 insertions(+), 24 deletions(-) create mode 100644 webui/src/utils/maskSecret.js diff --git a/internal/admin/handler_accounts_crud.go b/internal/admin/handler_accounts_crud.go index b9d7146..8ab25ee 100644 --- a/internal/admin/handler_accounts_crud.go +++ b/internal/admin/handler_accounts_crud.go @@ -58,14 +58,6 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) { for _, acc := range accounts[start:end] { testStatus, _ := h.Store.AccountTestStatus(acc.Identifier()) token := strings.TrimSpace(acc.Token) - preview := "" - if token != "" { - if len(token) > 20 { - preview = token[:20] + "..." - } else { - preview = token - } - } items = append(items, map[string]any{ "identifier": acc.Identifier(), "name": acc.Name, @@ -75,7 +67,7 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) { "proxy_id": acc.ProxyID, "has_password": acc.Password != "", "has_token": token != "", - "token_preview": preview, + "token_preview": maskSecretPreview(token), "test_status": testStatus, }) } diff --git a/internal/admin/handler_accounts_crud_test.go b/internal/admin/handler_accounts_crud_test.go index fb4d3cc..7b838c6 100644 --- a/internal/admin/handler_accounts_crud_test.go +++ b/internal/admin/handler_accounts_crud_test.go @@ -86,3 +86,33 @@ func TestUpdateAccountMetadataPreservesCredentials(t *testing.T) { t.Fatalf("password should be preserved, got %#v", acc) } } + +func TestListAccountsMasksTokenPreview(t *testing.T) { + h := newAdminTestHandler(t, `{ + "accounts":[{"email":"u@example.com","password":"pwd"}] + }`) + if err := h.Store.UpdateAccountToken("u@example.com", "abcdefgh"); err != nil { + t.Fatalf("seed runtime token: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/admin/accounts?page=1&page_size=10", nil) + rec := httptest.NewRecorder() + h.listAccounts(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String()) + } + + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response failed: %v", err) + } + items, _ := payload["items"].([]any) + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + first, _ := items[0].(map[string]any) + if got, _ := first["token_preview"].(string); got != "ab****gh" { + t.Fatalf("expected masked token preview, got %q", got) + } +} diff --git a/internal/admin/handler_config_read.go b/internal/admin/handler_config_read.go index ceeb523..20e5b1d 100644 --- a/internal/admin/handler_config_read.go +++ b/internal/admin/handler_config_read.go @@ -28,14 +28,6 @@ func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) { accounts := make([]map[string]any, 0, len(snap.Accounts)) for _, acc := range snap.Accounts { token := strings.TrimSpace(acc.Token) - preview := "" - if token != "" { - if len(token) > 20 { - preview = token[:20] + "..." - } else { - preview = token - } - } accounts = append(accounts, map[string]any{ "identifier": acc.Identifier(), "name": acc.Name, @@ -45,7 +37,7 @@ func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) { "proxy_id": acc.ProxyID, "has_password": strings.TrimSpace(acc.Password) != "", "has_token": token != "", - "token_preview": preview, + "token_preview": maskSecretPreview(token), }) } safe["accounts"] = accounts diff --git a/internal/admin/handler_test.go b/internal/admin/handler_test.go index a31e344..aa2db24 100644 --- a/internal/admin/handler_test.go +++ b/internal/admin/handler_test.go @@ -1,6 +1,9 @@ package admin import ( + "encoding/json" + "net/http" + "net/http/httptest" "sync/atomic" "testing" "time" @@ -33,6 +36,53 @@ func TestFieldStringNilToEmpty(t *testing.T) { } } +func TestMaskSecretPreviewKeepsOnlyFirstAndLastTwoChars(t *testing.T) { + cases := map[string]string{ + "": "", + "a": "*", + "ab": "**", + "abcd": "****", + "abcdef": "ab****ef", + "abc12345": "ab****45", + } + + for input, want := range cases { + if got := maskSecretPreview(input); got != want { + t.Fatalf("maskSecretPreview(%q)=%q want %q", input, got, want) + } + } +} + +func TestGetConfigMasksAccountTokenPreview(t *testing.T) { + h := newAdminTestHandler(t, `{ + "accounts":[{"email":"u@example.com","password":"pwd"}] + }`) + if err := h.Store.UpdateAccountToken("u@example.com", "abcdefgh"); err != nil { + t.Fatalf("seed runtime token: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/admin/config", nil) + rec := httptest.NewRecorder() + h.getConfig(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String()) + } + + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response failed: %v", err) + } + accounts, _ := payload["accounts"].([]any) + if len(accounts) != 1 { + t.Fatalf("expected 1 account, got %d", len(accounts)) + } + first, _ := accounts[0].(map[string]any) + if got, _ := first["token_preview"].(string); got != "ab****gh" { + t.Fatalf("expected masked token preview, got %q", got) + } +} + func TestRunAccountTestsConcurrentlyKeepsInputOrder(t *testing.T) { accounts := []config.Account{ {Email: "a@example.com"}, diff --git a/internal/admin/helpers.go b/internal/admin/helpers.go index c7af36f..c44dccf 100644 --- a/internal/admin/helpers.go +++ b/internal/admin/helpers.go @@ -46,6 +46,17 @@ func nilIfZero(v int64) any { return v } +func maskSecretPreview(secret string) string { + secret = strings.TrimSpace(secret) + if secret == "" { + return "" + } + if len(secret) <= 4 { + return strings.Repeat("*", len(secret)) + } + return secret[:2] + "****" + secret[len(secret)-2:] +} + func toStringSlice(v any) ([]string, bool) { arr, ok := v.([]any) if !ok { diff --git a/internal/testsuite/edge_cases_error_contract.go b/internal/testsuite/edge_cases_error_contract.go index 8a37e12..f177155 100644 --- a/internal/testsuite/edge_cases_error_contract.go +++ b/internal/testsuite/edge_cases_error_contract.go @@ -165,6 +165,12 @@ func (r *Runner) caseTokenRefreshManagedAccount(ctx context.Context, cc *caseCon } } cc.assert("has_token_after_refresh", hasToken, fmt.Sprintf("config=%s", string(cfgResp.Body))) - cc.assert("token_preview_changed_from_invalid", !strings.HasPrefix(preview, invalidToken[:20]), fmt.Sprintf("preview=%s invalid_prefix=%s", preview, invalidToken[:20])) + maskedInvalid := invalidToken + if len(maskedInvalid) <= 4 { + maskedInvalid = strings.Repeat("*", len(maskedInvalid)) + } else { + maskedInvalid = maskedInvalid[:2] + "****" + maskedInvalid[len(maskedInvalid)-2:] + } + cc.assert("token_preview_changed_from_invalid", preview != maskedInvalid, fmt.Sprintf("preview=%s invalid_mask=%s", preview, maskedInvalid)) return nil } diff --git a/webui/src/features/account/AddKeyModal.jsx b/webui/src/features/account/AddKeyModal.jsx index 061a1a5..a79e705 100644 --- a/webui/src/features/account/AddKeyModal.jsx +++ b/webui/src/features/account/AddKeyModal.jsx @@ -1,12 +1,15 @@ import { X } from 'lucide-react' import { v4 as uuidv4 } from 'uuid' +import { maskSecret } from '../../utils/maskSecret' + export default function AddKeyModal({ show, t, editingKey, newKey, setNewKey, loading, onClose, onAdd }) { if (!show) { return null } const isEditing = Boolean(editingKey?.key) + const displayKey = isEditing ? maskSecret(editingKey?.key || newKey.key) : newKey.key return (
@@ -25,7 +28,7 @@ export default function AddKeyModal({ show, t, editingKey, newKey, setNewKey, lo type="text" className={isEditing ? "input-field bg-muted/30 flex-1 cursor-not-allowed" : "input-field bg-[#09090b] flex-1"} placeholder={isEditing ? t('accountManager.keyReadonlyPlaceholder') : t('accountManager.newKeyPlaceholder')} - value={newKey.key} + value={displayKey} onChange={e => setNewKey({ ...newKey, key: e.target.value })} autoFocus={!isEditing} readOnly={isEditing} diff --git a/webui/src/features/account/ApiKeysPanel.jsx b/webui/src/features/account/ApiKeysPanel.jsx index 7030d8b..4905a5f 100644 --- a/webui/src/features/account/ApiKeysPanel.jsx +++ b/webui/src/features/account/ApiKeysPanel.jsx @@ -2,6 +2,8 @@ import { useState } from 'react' import { Check, ChevronDown, Copy, Pencil, Plus, Trash2 } from 'lucide-react' import clsx from 'clsx' +import { maskSecret } from '../../utils/maskSecret' + function fallbackCopyText(text) { const textArea = document.createElement('textarea') textArea.value = text @@ -102,7 +104,7 @@ export default function ApiKeysPanel({ className="font-mono text-sm bg-muted/50 px-3 py-1 rounded inline-block hover:bg-muted transition-colors" title={t('accountManager.copyKeyTitle')} > - {(item.key || '').slice(0, 16)}**** + {maskSecret(item.key)}
{item.remark || '-'}
{copiedKey === item.key && ( diff --git a/webui/src/features/apiTester/ConfigPanel.jsx b/webui/src/features/apiTester/ConfigPanel.jsx index ddf9848..2dbbfdd 100644 --- a/webui/src/features/apiTester/ConfigPanel.jsx +++ b/webui/src/features/apiTester/ConfigPanel.jsx @@ -10,6 +10,8 @@ import { } from 'lucide-react' import clsx from 'clsx' +import { maskSecret } from '../../utils/maskSecret' + export default function ConfigPanel({ t, configExpanded, @@ -40,6 +42,7 @@ export default function ConfigPanel({ } const selectedModel = models.find(m => m.id === model) || models[0] const SelectedModelIcon = selectedModel ? (iconMap[selectedModel.icon] || MessageSquare) : MessageSquare + const defaultKeyPreview = maskSecret(config.keys?.[0]) return (
setApiKey(e.target.value)} /> diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index 1770634..a0f72a4 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -238,7 +238,7 @@ "accountSelector": "Account", "autoRandom": "🤖 Auto / Random", "apiKeyOptional": "API Key (optional)", - "apiKeyDefault": "Default: ...{suffix}", + "apiKeyDefault": "Default: {preview}", "apiKeyPlaceholder": "Enter a custom key", "modeManaged": "Managed key mode (uses account pool).", "modeDirect": "Direct token mode (requires a valid DeepSeek token).", diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index 01b6655..e5ca592 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -238,7 +238,7 @@ "accountSelector": "选择账号", "autoRandom": "🤖 自动 / 随机", "apiKeyOptional": "API 密钥 (可选)", - "apiKeyDefault": "默认: ...{suffix}", + "apiKeyDefault": "默认: {preview}", "apiKeyPlaceholder": "输入自定义密钥", "modeManaged": "当前使用托管 key 模式(会走账号池)。", "modeDirect": "当前使用直通 token 模式(需填写有效 DeepSeek token)。", diff --git a/webui/src/utils/maskSecret.js b/webui/src/utils/maskSecret.js new file mode 100644 index 0000000..330b41e --- /dev/null +++ b/webui/src/utils/maskSecret.js @@ -0,0 +1,10 @@ +export function maskSecret(secret) { + const value = String(secret ?? '') + if (!value) { + return '' + } + if (value.length <= 4) { + return '*'.repeat(value.length) + } + return `${value.slice(0, 2)}****${value.slice(-2)}` +}