mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-01 23:15:27 +08:00
fix: fully mask web secret previews
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in">
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
</button>
|
||||
<div className="text-sm text-muted-foreground truncate">{item.remark || '-'}</div>
|
||||
{copiedKey === item.key && (
|
||||
|
||||
@@ -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 (
|
||||
<div className={clsx(
|
||||
@@ -158,7 +161,7 @@ export default function ConfigPanel({
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="w-full h-10 px-3 bg-muted/30 border border-border rounded-lg text-sm font-mono placeholder:text-muted-foreground/40 focus:outline-none focus:ring-1 focus:ring-ring focus:border-ring transition-all"
|
||||
placeholder={config.keys?.[0] ? t('apiTester.apiKeyDefault', { suffix: config.keys[0].slice(-6) }) : t('apiTester.apiKeyPlaceholder')}
|
||||
placeholder={defaultKeyPreview ? t('apiTester.apiKeyDefault', { preview: defaultKeyPreview }) : t('apiTester.apiKeyPlaceholder')}
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -238,7 +238,7 @@
|
||||
"accountSelector": "选择账号",
|
||||
"autoRandom": "🤖 自动 / 随机",
|
||||
"apiKeyOptional": "API 密钥 (可选)",
|
||||
"apiKeyDefault": "默认: ...{suffix}",
|
||||
"apiKeyDefault": "默认: {preview}",
|
||||
"apiKeyPlaceholder": "输入自定义密钥",
|
||||
"modeManaged": "当前使用托管 key 模式(会走账号池)。",
|
||||
"modeDirect": "当前使用直通 token 模式(需填写有效 DeepSeek token)。",
|
||||
|
||||
10
webui/src/utils/maskSecret.js
Normal file
10
webui/src/utils/maskSecret.js
Normal file
@@ -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)}`
|
||||
}
|
||||
Reference in New Issue
Block a user