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 (