Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/dashboard/src/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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://<vendor-endpoint>",
"region": "Region",
Expand Down
3 changes: 2 additions & 1 deletion apps/dashboard/src/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@
"addTitle": "新增 profile",
"editTitle": "编辑 {{name}}",
"addDescription": "填好后点保存。第一个 profile 自动成为 default。",
"editDescription": "保存会覆盖现有内容。",
"editDescription": "保存会覆盖现有内容;密钥字段留空会保持不变。",
"profileName": "profile 名",
"profileNamePlaceholder": "如 work / prod / acr-prod",
"setDefaultAfterSave": "保存后设为 default",
Expand All @@ -131,6 +131,7 @@
"siteUrl": "Site URL",
"clientId": "Client ID",
"clientSecret": "Client Secret",
"secretUnchangedPlaceholder": "留空则保持不变",
"endpoint": "Endpoint(aws-s3 可留空走 SDK 默认)",
"endpointPlaceholder": "https://<vendor-endpoint>",
"region": "Region",
Expand Down
46 changes: 40 additions & 6 deletions apps/dashboard/src/pages/SectionDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -480,7 +490,11 @@ const BackendFields: React.FC<BackendFieldsProps> = ({ sectionKey, profile, setP
<Label>{t("form.fields.clientSecret")}</Label>
<Input
type="password"
value={p.credentials?.clientSecret ?? ""}
value={secretInputValue(p.credentials?.clientSecret)}
placeholder={secretPlaceholder(
p.credentials?.clientSecret,
t("form.fields.secretUnchangedPlaceholder"),
)}
onChange={(e) =>
setProfile({
...p,
Expand Down Expand Up @@ -549,7 +563,11 @@ const BackendFields: React.FC<BackendFieldsProps> = ({ sectionKey, profile, setP
<Label>{t("form.fields.accessKeySecret")}</Label>
<Input
type="password"
value={p.credentials?.accessKeySecret ?? ""}
value={secretInputValue(p.credentials?.accessKeySecret)}
placeholder={secretPlaceholder(
p.credentials?.accessKeySecret,
t("form.fields.secretUnchangedPlaceholder"),
)}
onChange={(e) =>
setProfile({
...p,
Expand Down Expand Up @@ -603,7 +621,11 @@ const BackendFields: React.FC<BackendFieldsProps> = ({ sectionKey, profile, setP
<Label>{t("form.fields.apiToken")}</Label>
<Input
type="password"
value={p.credentials?.apiToken ?? ""}
value={secretInputValue(p.credentials?.apiToken)}
placeholder={secretPlaceholder(
p.credentials?.apiToken,
t("form.fields.secretUnchangedPlaceholder"),
)}
onChange={(e) =>
setProfile({
...p,
Expand Down Expand Up @@ -631,7 +653,11 @@ const BackendFields: React.FC<BackendFieldsProps> = ({ sectionKey, profile, setP
<Label>{t("form.fields.apiToken")}</Label>
<Input
type="password"
value={p.credentials?.apiToken ?? ""}
value={secretInputValue(p.credentials?.apiToken)}
placeholder={secretPlaceholder(
p.credentials?.apiToken,
t("form.fields.secretUnchangedPlaceholder"),
)}
onChange={(e) =>
setProfile({
...p,
Expand Down Expand Up @@ -659,7 +685,11 @@ const BackendFields: React.FC<BackendFieldsProps> = ({ sectionKey, profile, setP
<Label>{t("form.fields.apiToken")}</Label>
<Input
type="password"
value={p.credentials?.apiToken ?? ""}
value={secretInputValue(p.credentials?.apiToken)}
placeholder={secretPlaceholder(
p.credentials?.apiToken,
t("form.fields.secretUnchangedPlaceholder"),
)}
onChange={(e) =>
setProfile({
...p,
Expand Down Expand Up @@ -725,7 +755,11 @@ const BackendFields: React.FC<BackendFieldsProps> = ({ sectionKey, profile, setP
<Label>{t("form.fields.password")}</Label>
<Input
type="password"
value={p.credentials?.password ?? ""}
value={secretInputValue(p.credentials?.password)}
placeholder={secretPlaceholder(
p.credentials?.password,
t("form.fields.secretUnchangedPlaceholder"),
)}
onChange={(e) =>
setProfile({
...p,
Expand Down
104 changes: 104 additions & 0 deletions packages/cli/internal/serve/handlers_configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
69 changes: 69 additions & 0 deletions packages/cli/internal/serve/handlers_configure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"} {
Expand Down
27 changes: 27 additions & 0 deletions packages/cli/internal/serve/mask.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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)
Expand Down
Loading