From 56dc8cd6b7f56f76575ab986c07710cff1680ad9 Mon Sep 17 00:00:00 2001 From: caorushizi <84996057@qq.com> Date: Thu, 18 Jun 2026 00:02:40 +0800 Subject: [PATCH] fix masked profile secret preservation --- apps/dashboard/src/locales/en-US.json | 3 +- apps/dashboard/src/locales/zh-CN.json | 3 +- apps/dashboard/src/pages/SectionDetail.tsx | 46 +++++++- .../cli/internal/serve/handlers_configure.go | 104 ++++++++++++++++++ .../internal/serve/handlers_configure_test.go | 69 ++++++++++++ packages/cli/internal/serve/mask.go | 27 +++++ 6 files changed, 244 insertions(+), 8 deletions(-) diff --git a/apps/dashboard/src/locales/en-US.json b/apps/dashboard/src/locales/en-US.json index 8567eb0..60e7a3a 100644 --- a/apps/dashboard/src/locales/en-US.json +++ b/apps/dashboard/src/locales/en-US.json @@ -120,7 +120,7 @@ "addTitle": "New profile", "editTitle": "Edit {{name}}", "addDescription": "Fill in and save. The first profile in a section is set default automatically.", - "editDescription": "Saving overwrites the existing values.", + "editDescription": "Saving overwrites existing values; leave secret fields blank to keep them unchanged.", "profileName": "Profile name", "profileNamePlaceholder": "e.g. work / prod / acr-prod", "setDefaultAfterSave": "Set default after save", @@ -131,6 +131,7 @@ "siteUrl": "Site URL", "clientId": "Client ID", "clientSecret": "Client Secret", + "secretUnchangedPlaceholder": "Leave blank to keep unchanged", "endpoint": "Endpoint (leave blank for AWS S3 to use SDK defaults)", "endpointPlaceholder": "https://", "region": "Region", diff --git a/apps/dashboard/src/locales/zh-CN.json b/apps/dashboard/src/locales/zh-CN.json index 3bccac4..1f4a39c 100644 --- a/apps/dashboard/src/locales/zh-CN.json +++ b/apps/dashboard/src/locales/zh-CN.json @@ -120,7 +120,7 @@ "addTitle": "新增 profile", "editTitle": "编辑 {{name}}", "addDescription": "填好后点保存。第一个 profile 自动成为 default。", - "editDescription": "保存会覆盖现有内容。", + "editDescription": "保存会覆盖现有内容;密钥字段留空会保持不变。", "profileName": "profile 名", "profileNamePlaceholder": "如 work / prod / acr-prod", "setDefaultAfterSave": "保存后设为 default", @@ -131,6 +131,7 @@ "siteUrl": "Site URL", "clientId": "Client ID", "clientSecret": "Client Secret", + "secretUnchangedPlaceholder": "留空则保持不变", "endpoint": "Endpoint(aws-s3 可留空走 SDK 默认)", "endpointPlaceholder": "https://", "region": "Region", diff --git a/apps/dashboard/src/pages/SectionDetail.tsx b/apps/dashboard/src/pages/SectionDetail.tsx index 15865b6..f25ca2b 100644 --- a/apps/dashboard/src/pages/SectionDetail.tsx +++ b/apps/dashboard/src/pages/SectionDetail.tsx @@ -42,6 +42,16 @@ import { SECTION_META, type SectionKey, type SectionMeta, SECTION_KEYS } from "@ export type AnyProfile = ProfileByPair[keyof ProfileByPair]; +const MASKED_SECRET = "********"; + +function secretInputValue(value: string | undefined): string { + return value === MASKED_SECRET ? "" : (value ?? ""); +} + +function secretPlaceholder(value: string | undefined, placeholder: string): string | undefined { + return value === MASKED_SECRET ? placeholder : undefined; +} + function isKnownSection(domain: string, backend: string): SectionMeta | null { const key = `${domain}/${backend}`; if (!SECTION_KEYS.includes(key as SectionKey)) return null; @@ -480,7 +490,11 @@ const BackendFields: React.FC = ({ sectionKey, profile, setP setProfile({ ...p, @@ -549,7 +563,11 @@ const BackendFields: React.FC = ({ sectionKey, profile, setP setProfile({ ...p, @@ -603,7 +621,11 @@ const BackendFields: React.FC = ({ sectionKey, profile, setP setProfile({ ...p, @@ -631,7 +653,11 @@ const BackendFields: React.FC = ({ sectionKey, profile, setP setProfile({ ...p, @@ -659,7 +685,11 @@ const BackendFields: React.FC = ({ sectionKey, profile, setP setProfile({ ...p, @@ -725,7 +755,11 @@ const BackendFields: React.FC = ({ sectionKey, profile, setP setProfile({ ...p, diff --git a/packages/cli/internal/serve/handlers_configure.go b/packages/cli/internal/serve/handlers_configure.go index 960f0ed..d51701f 100644 --- a/packages/cli/internal/serve/handlers_configure.go +++ b/packages/cli/internal/serve/handlers_configure.go @@ -134,6 +134,10 @@ func handleUpsert(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, cliErrors.SERVE_PAYLOAD_INVALID, err.Error(), nil) return } + if err := preserveMaskedCredentials(domain, backend, body.Name, &p); err != nil { + writeProfileError(w, err) + return + } updated, err := profile.Upsert(profile.Domain(domain), backend, body.Name, p, body.Use) if err != nil { writeProfileError(w, err) @@ -381,6 +385,106 @@ func decodeSubProfile(domain, backend string, raw json.RawMessage) (profile.Prof return p, nil } +// preserveMaskedCredentials treats the public mask sentinel as "leave the +// existing secret unchanged" on updates. The UI edits the default masked +// GET payload; without this guard an unchanged password field would overwrite +// the real credential with "********". +func preserveMaskedCredentials(domain, backend, name string, p *profile.Profile) error { + if !profileContainsMaskedCredential(p) { + return nil + } + cfg, _, err := profile.Load() + if err != nil { + return err + } + switch { + case domain == "env" && backend == "infisical": + if p.Infisical == nil || p.Infisical.Credentials == nil || + p.Infisical.Credentials.ClientSecret != masked { + return nil + } + existing, ok := cfg.EnvInfisical.Profiles[name] + if ok && existing.Credentials != nil { + p.Infisical.Credentials.ClientSecret = existing.Credentials.ClientSecret + } + case domain == "deploy" && profile.IsS3Compatible(backend): + if p.S3 == nil || p.S3.Credentials == nil || + p.S3.Credentials.AccessKeySecret != masked { + return nil + } + sec := cfg.S3CompatSection(backend) + if sec == nil { + return nil + } + existing, ok := sec.Profiles[name] + if ok && existing.Credentials != nil { + p.S3.Credentials.AccessKeySecret = existing.Credentials.AccessKeySecret + } + case domain == "deploy" && backend == "vercel": + if p.Vercel == nil || p.Vercel.Credentials == nil || + p.Vercel.Credentials.APIToken != masked { + return nil + } + existing, ok := cfg.DeployVercel.Profiles[name] + if ok && existing.Credentials != nil { + p.Vercel.Credentials.APIToken = existing.Credentials.APIToken + } + case domain == "deploy" && backend == "cloudflare": + if p.Cloudflare == nil || p.Cloudflare.Credentials == nil || + p.Cloudflare.Credentials.APIToken != masked { + return nil + } + existing, ok := cfg.DeployCloudflare.Profiles[name] + if ok && existing.Credentials != nil { + p.Cloudflare.Credentials.APIToken = existing.Credentials.APIToken + } + case domain == "deploy" && backend == "edgeone": + if p.EdgeOne == nil || p.EdgeOne.Credentials == nil || + p.EdgeOne.Credentials.APIToken != masked { + return nil + } + existing, ok := cfg.DeployEdgeOne.Profiles[name] + if ok && existing.Credentials != nil { + p.EdgeOne.Credentials.APIToken = existing.Credentials.APIToken + } + case domain == "container" && profile.IsContainerKind(backend): + if p.Container == nil || p.Container.Credentials == nil || + p.Container.Credentials.Password != masked { + return nil + } + sec := cfg.ContainerKindSection(backend) + if sec == nil { + return nil + } + existing, ok := sec.Profiles[name] + if ok && existing.Credentials != nil { + p.Container.Credentials.Password = existing.Credentials.Password + } + } + return nil +} + +func profileContainsMaskedCredential(p *profile.Profile) bool { + switch { + case p == nil: + return false + case p.Infisical != nil && p.Infisical.Credentials != nil: + return p.Infisical.Credentials.ClientSecret == masked + case p.S3 != nil && p.S3.Credentials != nil: + return p.S3.Credentials.AccessKeySecret == masked + case p.Vercel != nil && p.Vercel.Credentials != nil: + return p.Vercel.Credentials.APIToken == masked + case p.Cloudflare != nil && p.Cloudflare.Credentials != nil: + return p.Cloudflare.Credentials.APIToken == masked + case p.EdgeOne != nil && p.EdgeOne.Credentials != nil: + return p.EdgeOne.Credentials.APIToken == masked + case p.Container != nil && p.Container.Credentials != nil: + return p.Container.Credentials.Password == masked + default: + return false + } +} + func decodeJSON(r *http.Request, dst any) error { if r.Body == nil { return errors.New("empty body") diff --git a/packages/cli/internal/serve/handlers_configure_test.go b/packages/cli/internal/serve/handlers_configure_test.go index aa6d24a..cf7c2a4 100644 --- a/packages/cli/internal/serve/handlers_configure_test.go +++ b/packages/cli/internal/serve/handlers_configure_test.go @@ -155,6 +155,75 @@ func TestGetSection_MasksByDefault_RevealsOnQuery(t *testing.T) { } } +func TestUpsert_MaskedCredentialPreservesExistingSecret(t *testing.T) { + srv, _ := newTestServer(t) + body := strings.NewReader(`{"name":"work","profile":{"siteUrl":"https://x","credentials":{"clientId":"cid","clientSecret":"original-secret"}}}`) + if res, raw := authedRequest(t, srv, http.MethodPost, "/api/configure/env/infisical", body); res.StatusCode != 200 { + t.Fatalf("seed: %d (%s)", res.StatusCode, raw) + } + + update := strings.NewReader(`{"name":"work","profile":{"siteUrl":"https://updated","credentials":{"clientId":"cid-rotated","clientSecret":"********"}}}`) + if res, raw := authedRequest(t, srv, http.MethodPost, "/api/configure/env/infisical", update); res.StatusCode != 200 { + t.Fatalf("update: %d (%s)", res.StatusCode, raw) + } + + cfg, _, err := profile.Load() + if err != nil { + t.Fatalf("profile.Load: %v", err) + } + got := cfg.EnvInfisical.Profiles["work"] + if got.SiteURL != "https://updated" { + t.Errorf("siteUrl should update, got %q", got.SiteURL) + } + if got.Credentials == nil { + t.Fatal("credentials missing") + } + if got.Credentials.ClientID != "cid-rotated" { + t.Errorf("clientId should update, got %q", got.Credentials.ClientID) + } + if got.Credentials.ClientSecret != "original-secret" { + t.Errorf("clientSecret should be preserved, got %q", got.Credentials.ClientSecret) + } +} + +func TestGetSection_MasksDeployTokensByDefault(t *testing.T) { + srv, _ := newTestServer(t) + cases := []struct { + path string + body string + secret string + }{ + { + path: "/api/configure/deploy/cloudflare", + body: `{"name":"work","profile":{"accountId":"acct","credentials":{"apiToken":"cloudflare-secret"}}}`, + secret: "cloudflare-secret", + }, + { + path: "/api/configure/deploy/edgeone", + body: `{"name":"work","profile":{"region":"ap-guangzhou","credentials":{"apiToken":"edgeone-secret"}}}`, + secret: "edgeone-secret", + }, + } + + for _, tc := range cases { + if res, raw := authedRequest(t, srv, http.MethodPost, tc.path, strings.NewReader(tc.body)); res.StatusCode != 200 { + t.Fatalf("seed %s: %d (%s)", tc.path, res.StatusCode, raw) + } + _, raw := authedRequest(t, srv, http.MethodGet, tc.path, nil) + if strings.Contains(string(raw), tc.secret) { + t.Errorf("%s leaked default token: %s", tc.path, raw) + } + if !strings.Contains(string(raw), "********") { + t.Errorf("%s should contain masked sentinel; got %s", tc.path, raw) + } + + _, raw = authedRequest(t, srv, http.MethodGet, tc.path+"?reveal=1", nil) + if !strings.Contains(string(raw), tc.secret) { + t.Errorf("%s reveal=1 should expose plaintext; got %s", tc.path, raw) + } + } +} + func TestUse_SwitchesDefault(t *testing.T) { srv, _ := newTestServer(t) for _, n := range []string{"work", "personal"} { diff --git a/packages/cli/internal/serve/mask.go b/packages/cli/internal/serve/mask.go index a70f684..f1afe2b 100644 --- a/packages/cli/internal/serve/mask.go +++ b/packages/cli/internal/serve/mask.go @@ -74,6 +74,31 @@ func maskVercel(p profile.VercelProfile) profile.VercelProfile { return p } +// maskCloudflare returns a copy with the API token redacted. AccountID is +// left visible for the same reason Vercel team is: it helps identify the +// profile without exposing the bearer credential. +func maskCloudflare(p profile.CloudflareProfile) profile.CloudflareProfile { + if p.Credentials == nil { + return p + } + creds := *p.Credentials + creds.APIToken = masked + p.Credentials = &creds + return p +} + +// maskEdgeOne returns a copy with the API token redacted. Region is not a +// secret and stays visible in the list view. +func maskEdgeOne(p profile.EdgeOneProfile) profile.EdgeOneProfile { + if p.Credentials == nil { + return p + } + creds := *p.Credentials + creds.APIToken = masked + p.Credentials = &creds + return p +} + // maskDotenv is a no-op: the dotenv profile struct is empty. Same // rationale as maskKustomize. func maskDotenv(p profile.DotenvProfile) profile.DotenvProfile { return p } @@ -90,6 +115,8 @@ func maskConfig(c profile.Config) profile.Config { } c.DeployKustomize.Profiles = maskMap(c.DeployKustomize.Profiles, maskKustomize) c.DeployVercel.Profiles = maskMap(c.DeployVercel.Profiles, maskVercel) + c.DeployCloudflare.Profiles = maskMap(c.DeployCloudflare.Profiles, maskCloudflare) + c.DeployEdgeOne.Profiles = maskMap(c.DeployEdgeOne.Profiles, maskEdgeOne) for _, kind := range profile.ContainerKinds() { sec := c.ContainerKindSection(kind) sec.Profiles = maskMap(sec.Profiles, maskContainer)