diff --git a/cmd/dashboard/controller/credential_redaction_test.go b/cmd/dashboard/controller/credential_redaction_test.go new file mode 100644 index 00000000..bb4ff01d --- /dev/null +++ b/cmd/dashboard/controller/credential_redaction_test.go @@ -0,0 +1,174 @@ +package controller + +import ( + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + + "github.com/nezhahq/nezha/model" + "github.com/nezhahq/nezha/service/singleton" +) + +func TestListDDNS_RedactsCredentials(t *testing.T) { + defer setupTenancyTest(t)() + + p := model.DDNSProfile{ + Common: model.Common{UserID: 10}, + Name: "cf", + Provider: "cloudflare", + AccessID: "id", + AccessSecret: "super-secret-token", + WebhookHeaders: `{"Authorization":"Bearer xxx"}`, + } + require.NoError(t, singleton.DB.Create(&p).Error) + singleton.DDNSShared.InsertForTest(&p) + + c := ctxAs(10, model.RoleAdmin) + out, err := listDDNS(c) + require.NoError(t, err) + require.Len(t, out, 1) + require.Empty(t, out[0].AccessSecret, "access_secret must be redacted in list response") + require.Empty(t, out[0].WebhookHeaders, "webhook_headers must be redacted in list response") + require.Equal(t, "id", out[0].AccessID, "non-secret fields must be preserved") + + var stored model.DDNSProfile + require.NoError(t, singleton.DB.First(&stored, p.ID).Error) + require.Equal(t, "super-secret-token", stored.AccessSecret, "redaction must not mutate stored data") +} + +func TestListNotification_RedactsCredentials(t *testing.T) { + defer setupTenancyTest(t)() + + n := model.Notification{ + Common: model.Common{UserID: 10}, + Name: "slack", + URL: "https://hooks.slack.com/services/T/B/secret", + RequestHeader: `{"Authorization":"Bearer xxx"}`, + RequestBody: `{"text":"#NEZHA#"}`, + } + require.NoError(t, singleton.DB.Create(&n).Error) + singleton.NotificationShared.InsertForTest(&n) + + c := ctxAs(10, model.RoleAdmin) + out, err := listNotification(c) + require.NoError(t, err) + require.Len(t, out, 1) + require.Empty(t, out[0].URL, "url must be redacted in list response") + require.Empty(t, out[0].RequestHeader, "request_header must be redacted in list response") + require.Empty(t, out[0].RequestBody, "request_body must be redacted in list response") + require.Equal(t, "slack", out[0].Name, "non-secret fields must be preserved") + + var stored model.Notification + require.NoError(t, singleton.DB.First(&stored, n.ID).Error) + require.Equal(t, "https://hooks.slack.com/services/T/B/secret", stored.URL, + "redaction must not mutate stored data") +} + +func TestUpdateDDNS_EmptySecretPreservesStored(t *testing.T) { + defer setupTenancyTest(t)() + + existing := model.DDNSProfile{ + Common: model.Common{UserID: 10}, + Name: "cf", + Provider: "webhook", + AccessID: "id", + AccessSecret: "keep-me", + WebhookURL: "http://127.0.0.1/", + WebhookMethod: 1, + WebhookHeaders: `{"X-Token":"keep-header"}`, + } + require.NoError(t, singleton.DB.Create(&existing).Error) + singleton.DDNSShared.InsertForTest(&existing) + + c := ctxAsMemberWithBody(10, map[string]any{ + "name": "cf-renamed", + "provider": "webhook", + "access_id": "id", + "access_secret": "", + "webhook_url": "http://127.0.0.1/", + "webhook_method": 1, + "webhook_request_type": 1, + "webhook_headers": "", + "max_retries": 3, + }) + c.Params = gin.Params{{Key: "id", Value: itoa(existing.ID)}} + _, err := updateDDNS(c) + require.NoError(t, err) + + var after model.DDNSProfile + require.NoError(t, singleton.DB.First(&after, existing.ID).Error) + require.Equal(t, "cf-renamed", after.Name, "non-secret edits must apply") + require.Equal(t, "keep-me", after.AccessSecret, "empty submitted secret must preserve stored value") + require.Equal(t, `{"X-Token":"keep-header"}`, after.WebhookHeaders, + "empty submitted headers must preserve stored value") +} + +func TestUpdateDDNS_NonEmptySecretOverwrites(t *testing.T) { + defer setupTenancyTest(t)() + + existing := model.DDNSProfile{ + Common: model.Common{UserID: 10}, + Name: "cf", + Provider: "webhook", + AccessSecret: "old", + WebhookURL: "http://127.0.0.1/", + WebhookMethod: 1, + } + require.NoError(t, singleton.DB.Create(&existing).Error) + singleton.DDNSShared.InsertForTest(&existing) + + c := ctxAsMemberWithBody(10, map[string]any{ + "name": "cf", + "provider": "webhook", + "access_secret": "new-secret", + "webhook_url": "http://127.0.0.1/", + "webhook_method": 1, + "webhook_request_type": 1, + "max_retries": 3, + }) + c.Params = gin.Params{{Key: "id", Value: itoa(existing.ID)}} + _, err := updateDDNS(c) + require.NoError(t, err) + + var after model.DDNSProfile + require.NoError(t, singleton.DB.First(&after, existing.ID).Error) + require.Equal(t, "new-secret", after.AccessSecret, "non-empty submitted secret must overwrite") +} + +func TestUpdateNotification_EmptyFieldsPreserveStored(t *testing.T) { + defer setupTenancyTest(t)() + + existing := model.Notification{ + Common: model.Common{UserID: 10}, + Name: "slack", + URL: "https://hooks.slack.com/services/keep", + RequestMethod: model.NotificationRequestMethodGET, + RequestType: model.NotificationRequestTypeJSON, + RequestHeader: `{"Authorization":"keep"}`, + RequestBody: "", + } + require.NoError(t, singleton.DB.Create(&existing).Error) + singleton.NotificationShared.InsertForTest(&existing) + + c := ctxAsMemberWithBody(10, map[string]any{ + "name": "slack-renamed", + "url": "", + "request_method": model.NotificationRequestMethodGET, + "request_type": model.NotificationRequestTypeJSON, + "request_header": "", + "request_body": "", + "skip_check": true, + }) + c.Params = gin.Params{{Key: "id", Value: itoa(existing.ID)}} + _, err := updateNotification(c) + require.NoError(t, err) + + var after model.Notification + require.NoError(t, singleton.DB.First(&after, existing.ID).Error) + require.Equal(t, "slack-renamed", after.Name, "non-secret edits must apply") + require.Equal(t, "https://hooks.slack.com/services/keep", after.URL, + "empty submitted url must preserve stored value") + require.Equal(t, `{"Authorization":"keep"}`, after.RequestHeader, + "empty submitted request_header must preserve stored value") +} diff --git a/cmd/dashboard/controller/ddns.go b/cmd/dashboard/controller/ddns.go index 5b204a47..a2a2c20e 100644 --- a/cmd/dashboard/controller/ddns.go +++ b/cmd/dashboard/controller/ddns.go @@ -30,6 +30,13 @@ func listDDNS(c *gin.Context) ([]*model.DDNSProfile, error) { return nil, err } + // 列表端点不回显写入态凭据:ddnsProfiles 是 copier 复制出的副本,置零安全, + // 不影响 singleton 内原始数据。 + for _, p := range ddnsProfiles { + p.AccessSecret = "" + p.WebhookHeaders = "" + } + return ddnsProfiles, nil } @@ -137,12 +144,18 @@ func updateDDNS(c *gin.Context) (any, error) { p.Provider = df.Provider p.Domains = df.Domains p.AccessID = df.AccessID - p.AccessSecret = df.AccessSecret p.WebhookURL = df.WebhookURL p.WebhookMethod = df.WebhookMethod p.WebhookRequestType = df.WebhookRequestType p.WebhookRequestBody = df.WebhookRequestBody - p.WebhookHeaders = df.WebhookHeaders + + // 凭据在列表接口已脱敏,前端无法回填;空值视为"不修改",保留旧值避免误清空。 + if df.AccessSecret != "" { + p.AccessSecret = df.AccessSecret + } + if df.WebhookHeaders != "" { + p.WebhookHeaders = df.WebhookHeaders + } for n, domain := range p.Domains { // IDN to ASCII diff --git a/cmd/dashboard/controller/notification.go b/cmd/dashboard/controller/notification.go index 774ecf22..43403e34 100644 --- a/cmd/dashboard/controller/notification.go +++ b/cmd/dashboard/controller/notification.go @@ -29,6 +29,14 @@ func listNotification(c *gin.Context) ([]*model.Notification, error) { if err := copier.Copy(¬ifications, &slist); err != nil { return nil, err } + + // 列表端点不回显写入态凭据:notifications 是 copier 复制出的副本,置零安全, + // 不影响 singleton 内原始数据。 + for _, n := range notifications { + n.URL = "" + n.RequestHeader = "" + n.RequestBody = "" + } return notifications, nil } @@ -117,14 +125,22 @@ func updateNotification(c *gin.Context) (any, error) { n.Name = nf.Name n.RequestMethod = nf.RequestMethod n.RequestType = nf.RequestType - n.RequestHeader = nf.RequestHeader - n.RequestBody = nf.RequestBody - n.URL = nf.URL verifyTLS := nf.VerifyTLS n.VerifyTLS = &verifyTLS formatMetricUnits := nf.FormatMetricUnits n.FormatMetricUnits = &formatMetricUnits + // 凭据在列表接口已脱敏,前端无法回填;空值视为"不修改",保留旧值避免误清空。 + if nf.URL != "" { + n.URL = nf.URL + } + if nf.RequestHeader != "" { + n.RequestHeader = nf.RequestHeader + } + if nf.RequestBody != "" { + n.RequestBody = nf.RequestBody + } + ns := model.NotificationServerBundle{ Notification: &n, Server: nil, diff --git a/service/singleton/testhelpers.go b/service/singleton/testhelpers.go index 459a531a..eb60b9c6 100644 --- a/service/singleton/testhelpers.go +++ b/service/singleton/testhelpers.go @@ -43,6 +43,7 @@ func (c *DDNSClass) InsertForTest(p *model.DDNSProfile) { c.listMu.Lock() c.list[p.ID] = p c.listMu.Unlock() + c.sortList() } // NewEmptyNotificationClassForTest 构造空 NotificationClass。 @@ -62,4 +63,5 @@ func (c *NotificationClass) InsertForTest(n *model.Notification) { c.listMu.Lock() c.list[n.ID] = n c.listMu.Unlock() + c.sortList() }