diff --git a/internal/server/settings_api.go b/internal/server/settings_api.go index d28afcd..bbd5101 100644 --- a/internal/server/settings_api.go +++ b/internal/server/settings_api.go @@ -40,6 +40,7 @@ var notificationChannels = []string{ "webhook", "bark", "pushplus", + "wecom", } var notificationFields = map[string]map[string]string{ @@ -61,6 +62,9 @@ var notificationFields = map[string]map[string]string{ "pushplus": { "token": "string", "topic": "string", "channel": "string", }, + "wecom": { + "urls": "strings", "payload_template": "string", + }, } // routeSettingsAPI is intentionally independent of the main router so it can @@ -291,6 +295,11 @@ func validateNotificationField( return fmt.Errorf("%s is not a valid email address", field) } } + if channel == "wecom" && name == "payload_template" && value != "" { + if _, err := renderWecomPayload(value, wecomTestValues(time.Unix(0, 0))); err != nil { + return fmt.Errorf("%s is not a valid JSON template: %w", field, err) + } + } case "integer": var value int if err := json.Unmarshal(raw, &value); err != nil { @@ -324,6 +333,9 @@ func validateNotificationField( return fmt.Errorf("%s contains an invalid value", field) } if name == "urls" { + if channel == "wecom" && value == store.SecretMask { + continue + } if _, err := parseOutboundURL(value, false); err != nil { return fmt.Errorf("%s contains an invalid HTTP URL", field) } @@ -375,7 +387,7 @@ func (s *Server) handleNotificationTest( writeError(w, http.StatusNotFound, "not_found", "notification channel was not found") return } - if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" { + if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" { writeError( w, http.StatusNotImplemented, @@ -426,6 +438,8 @@ func (s *Server) handleNotificationTest( err = sendEmailNotificationTest(r.Context(), resolved) case "bark": err = sendBarkNotificationTest(r.Context(), resolved) + case "wecom": + err = sendWecomNotificationTest(r.Context(), resolved) } if err != nil { redacted := store.RedactText(err.Error(), provider) @@ -549,6 +563,8 @@ func validateNotificationTestConfig(channel string, config map[string]any) error if len(urls) > 8 { return errors.New("bark test is limited to 8 URLs") } + case "wecom": + return validateWecomNotificationConfig(config) case "telegram": token := configString(config, "bot_token") if token == "" || token == store.SecretMask { diff --git a/internal/server/settings_api_test.go b/internal/server/settings_api_test.go index 0903e1e..4c02eb0 100644 --- a/internal/server/settings_api_test.go +++ b/internal/server/settings_api_test.go @@ -135,6 +135,50 @@ func TestNotificationSettingsAlwaysReturnsFiveChannelsAndPreservesSecrets(t *tes } } +func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) { + test := newSettingsAPITest(t) + webhookURL := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=wecom-secret" + template := `{"msgtype":"text","text":{"content":{{message}}}}` + first, err := json.Marshal(map[string]any{ + "wecom": map[string]any{ + "enabled": true, "urls": []string{webhookURL}, "payload_template": template, + }, + }) + if err != nil { + t.Fatal(err) + } + recorder := test.request(t, http.MethodPut, "/api/settings/notifications", string(first)) + if recorder.Code != http.StatusOK { + t.Fatalf("first PUT status = %d, body = %s", recorder.Code, recorder.Body) + } + if bytes.Contains(recorder.Body.Bytes(), []byte("wecom-secret")) { + t.Fatalf("PUT response leaked webhook URL: %s", recorder.Body) + } + response := decodeSettingsResponse(t, recorder) + wecom := response["data"].(map[string]any)["wecom"].(map[string]any) + urls, ok := wecom["urls"].([]any) + if !ok || len(urls) != 1 || urls[0] != store.SecretMask { + t.Fatalf("redacted WeCom URLs = %#v", wecom["urls"]) + } + + second, err := json.Marshal(map[string]any{ + "wecom": map[string]any{ + "enabled": true, "urls": []string{store.SecretMask}, "payload_template": template, + }, + }) + if err != nil { + t.Fatal(err) + } + recorder = test.request(t, http.MethodPut, "/api/settings/notifications", string(second)) + if recorder.Code != http.StatusOK { + t.Fatalf("masked PUT status = %d, body = %s", recorder.Code, recorder.Body) + } + stored, err := test.database.NotificationSetting(context.Background(), "wecom") + if err != nil || !bytes.Contains(stored.Config, []byte("wecom-secret")) { + t.Fatalf("stored WeCom config = %s, err = %v", stored.Config, err) + } +} + func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) { test := newSettingsAPITest(t) cases := []struct { diff --git a/internal/server/wecom_notification.go b/internal/server/wecom_notification.go index 2b74885..7bb2230 100644 --- a/internal/server/wecom_notification.go +++ b/internal/server/wecom_notification.go @@ -1,11 +1,15 @@ package server import ( + "bytes" + "context" "encoding/json" "errors" "fmt" + "io" "net/http" "strings" + "time" ) var wecomTemplateVariableNames = []string{ @@ -52,3 +56,69 @@ func validateWecomResponse(status int, body []byte) error { } return nil } + +func wecomTestValues(now time.Time) wecomTemplateValues { + return wecomTemplateValues{ + "event": "test", "title": "vocat", "message": "vocat notification test", + "timestamp": now.UTC().Format(time.RFC3339), + } +} + +func validateWecomNotificationConfig(config map[string]any) error { + urls := configStrings(config, "urls") + if len(urls) == 0 { + return errors.New("wecom.urls must contain at least one URL") + } + if len(urls) > 8 { + return errors.New("wecom.urls cannot contain more than 8 URLs") + } + template := configString(config, "payload_template") + if template == "" { + return errors.New("wecom.payload_template is required") + } + _, err := renderWecomPayload(template, wecomTestValues(time.Unix(0, 0))) + return err +} + +func sendWecomNotification(ctx context.Context, config map[string]any, values wecomTemplateValues) error { + payload, err := renderWecomPayload(configString(config, "payload_template"), values) + if err != nil { + return err + } + client, err := restrictedHTTPClient(ctx, 8*time.Second, "") + if err != nil { + return err + } + for _, destination := range configStrings(config, "urls") { + parsed, err := validateOutboundURL(ctx, destination, false) + if err != nil { + return err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("create WeCom notification request: %w", err) + } + request.Header.Set("Content-Type", "application/json; charset=utf-8") + request.Header.Set("User-Agent", "vocat-wecom-notification/1") + response, err := client.Do(request) + if err != nil { + return fmt.Errorf("send WeCom notification: %w", err) + } + body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10)) + closeErr := response.Body.Close() + if readErr != nil { + return fmt.Errorf("read WeCom response: %w", readErr) + } + if closeErr != nil { + return fmt.Errorf("close WeCom response: %w", closeErr) + } + if err := validateWecomResponse(response.StatusCode, body); err != nil { + return err + } + } + return nil +} + +func sendWecomNotificationTest(ctx context.Context, config map[string]any) error { + return sendWecomNotification(ctx, config, wecomTestValues(time.Now())) +} diff --git a/internal/store/domain_test.go b/internal/store/domain_test.go index 11393be..4badb62 100644 --- a/internal/store/domain_test.go +++ b/internal/store/domain_test.go @@ -737,6 +737,43 @@ func TestNotificationAndAppSecretPreservation(t *testing.T) { } } +func TestNotificationArraySecretPreservation(t *testing.T) { + ctx := context.Background() + database := openTestStore(t, ":memory:") + originalURL := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=first-secret" + if err := database.UpsertNotificationSetting(ctx, NotificationSetting{ + Channel: "wecom", Enabled: true, + Config: json.RawMessage(`{"urls":["` + originalURL + `"]}`), + }); err != nil { + t.Fatal(err) + } + setting, err := database.NotificationSetting(ctx, "wecom") + if err != nil { + t.Fatal(err) + } + var redacted map[string]any + if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil { + t.Fatal(err) + } + urls, ok := redacted["urls"].([]any) + if !ok || len(urls) != 1 || urls[0] != SecretMask { + t.Fatalf("redacted URLs = %#v", redacted["urls"]) + } + if err := database.UpsertNotificationSetting(ctx, NotificationSetting{ + Channel: "wecom", Enabled: true, + Config: json.RawMessage(`{"urls":["` + SecretMask + `","https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=second-secret"]}`), + }); err != nil { + t.Fatal(err) + } + setting, err = database.NotificationSetting(ctx, "wecom") + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(setting.Config, []byte(originalURL)) || !bytes.Contains(setting.Config, []byte("second-secret")) { + t.Fatalf("stored URLs = %s", setting.Config) + } +} + func TestEventsPoliciesAndTraffic(t *testing.T) { ctx := context.Background() database := openTestStore(t, ":memory:") diff --git a/internal/store/models.go b/internal/store/models.go index 4e9a772..d255be9 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -339,12 +339,9 @@ func (value NotificationSetting) SensitiveValues() []string { } values := make([]string, 0, len(value.SensitiveFields)) for _, field := range value.SensitiveFields { - if secret, ok := getJSONPath(document, field).(string); ok && - secret != "" && secret != SecretMask { - values = append(values, secret) - } + collectJSONStringValues(getJSONPath(document, field), &values) } - return values + return uniqueNonemptyStrings(values) } type AppSetting struct { @@ -575,8 +572,8 @@ func redactJSONFields(value json.RawMessage, fields []string, replacement string return json.RawMessage(`{}`) } for _, field := range fields { - if getJSONPath(document, field) != nil { - setJSONPath(document, field, replacement) + if current := getJSONPath(document, field); current != nil { + setJSONPath(document, field, redactJSONValue(current, replacement)) } } encoded, err := json.Marshal(document) @@ -601,16 +598,55 @@ func mergeJSONSecrets( } for _, field := range fields { value := getJSONPath(next, field) - text, stringValue := value.(string) - if value == nil || (stringValue && (text == "" || text == SecretMask)) { - if previous := getJSONPath(current, field); previous != nil { - setJSONPath(next, field, previous) - } + if previous := getJSONPath(current, field); previous != nil { + setJSONPath(next, field, mergeJSONSecretValue(value, previous)) } } return json.Marshal(next) } +func redactJSONValue(value any, replacement string) any { + switch typed := value.(type) { + case string: + return replacement + case []any: + result := make([]any, len(typed)) + for index, item := range typed { + result[index] = redactJSONValue(item, replacement) + } + return result + default: + return replacement + } +} + +func mergeJSONSecretValue(incoming, existing any) any { + if incoming == nil { + return existing + } + switch next := incoming.(type) { + case string: + if next == "" || next == SecretMask { + return existing + } + case []any: + previous, ok := existing.([]any) + if !ok { + return incoming + } + merged := make([]any, len(next)) + for index, value := range next { + if index < len(previous) { + merged[index] = mergeJSONSecretValue(value, previous[index]) + } else { + merged[index] = value + } + } + return merged + } + return incoming +} + func getJSONPath(document map[string]any, path string) any { if strings.TrimSpace(path) == "" { return nil diff --git a/internal/store/settings.go b/internal/store/settings.go index c569121..7998bfd 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -22,6 +22,8 @@ func DefaultNotificationSensitiveFields(channel string) []string { return []string{"secret"} case "pushplus": return []string{"token"} + case "wecom": + return []string{"urls"} default: return nil }