From 83682e203d01d3a2fad5b42a80119994def4507e Mon Sep 17 00:00:00 2001 From: Stefan Rinke Date: Mon, 16 Feb 2026 09:00:08 +0100 Subject: [PATCH] masked sensitive improved schema --- config/config.schema.json | 84 ++++++++++++++--- pkg/channels/webui.go | 120 +++++++++++++++++++++++- pkg/channels/webui_admin_config_test.go | 96 +++++++++++++++++++ 3 files changed, 284 insertions(+), 16 deletions(-) create mode 100644 pkg/channels/webui_admin_config_test.go diff --git a/config/config.schema.json b/config/config.schema.json index b57907ff6..311ba20c7 100644 --- a/config/config.schema.json +++ b/config/config.schema.json @@ -93,6 +93,7 @@ "ChannelsConfig": { "type": "object", "additionalProperties": false, + "description": "Channel (messenger) integrations. Enable the channels you want to receive messages from.", "required": [ "whatsapp", "telegram", @@ -123,6 +124,7 @@ "WhatsAppConfig": { "type": "object", "additionalProperties": false, + "description": "WhatsApp channel integration (via a bridge service).", "required": ["enabled", "bridge_url", "allow_from"], "properties": { "enabled": { "type": "boolean", "default": false }, @@ -133,6 +135,7 @@ "TelegramConfig": { "type": "object", "additionalProperties": false, + "description": "Telegram bot channel integration.", "required": ["enabled", "token", "proxy", "allow_from"], "properties": { "enabled": { "type": "boolean", "default": false }, @@ -144,6 +147,7 @@ "FeishuConfig": { "type": "object", "additionalProperties": false, + "description": "Feishu / Lark (飞书 / Lark) bot channel integration.", "required": [ "enabled", "app_id", @@ -164,6 +168,7 @@ "DiscordConfig": { "type": "object", "additionalProperties": false, + "description": "Discord bot channel integration.", "required": ["enabled", "token", "allow_from"], "properties": { "enabled": { "type": "boolean", "default": false }, @@ -174,6 +179,7 @@ "MaixCamConfig": { "type": "object", "additionalProperties": false, + "description": "Sipeed MaixCam device channel integration.", "required": ["enabled", "host", "port", "allow_from"], "properties": { "enabled": { "type": "boolean", "default": false }, @@ -190,6 +196,7 @@ "QQConfig": { "type": "object", "additionalProperties": false, + "description": "Tencent QQ (腾讯 QQ) bot/channel integration.", "required": ["enabled", "app_id", "app_secret", "allow_from"], "properties": { "enabled": { "type": "boolean", "default": false }, @@ -201,6 +208,7 @@ "DingTalkConfig": { "type": "object", "additionalProperties": false, + "description": "DingTalk / DingDing (钉钉) bot/channel integration.", "required": ["enabled", "client_id", "client_secret", "allow_from"], "properties": { "enabled": { "type": "boolean", "default": false }, @@ -212,6 +220,7 @@ "SlackConfig": { "type": "object", "additionalProperties": false, + "description": "Slack bot channel integration.", "required": ["enabled", "bot_token", "app_token", "allow_from"], "properties": { "enabled": { "type": "boolean", "default": false }, @@ -223,6 +232,7 @@ "LINEConfig": { "type": "object", "additionalProperties": false, + "description": "LINE Messaging API channel integration.", "required": [ "enabled", "channel_secret", @@ -250,6 +260,7 @@ "OneBotConfig": { "type": "object", "additionalProperties": false, + "description": "OneBot (CQHTTP) compatible channel integration (often used for QQ bots via external bridges).", "required": [ "enabled", "ws_url", @@ -274,6 +285,7 @@ "WebUIConfig": { "type": "object", "additionalProperties": false, + "description": "Built-in web UI channel. Serves the browser chat UI and /admin config editor over HTTP.", "required": ["enabled"], "properties": { "enabled": { "type": "boolean", "default": true } @@ -282,6 +294,7 @@ "ProvidersConfig": { "type": "object", "additionalProperties": false, + "description": "Model provider configuration. Each key enables and configures a provider integration (API key, base URL overrides, etc).", "required": [ "anthropic", "openai", @@ -297,18 +310,54 @@ "github_copilot" ], "properties": { - "anthropic": { "$ref": "#/$defs/ProviderConfig" }, - "openai": { "$ref": "#/$defs/ProviderConfig" }, - "openrouter": { "$ref": "#/$defs/ProviderConfig" }, - "groq": { "$ref": "#/$defs/ProviderConfig" }, - "zhipu": { "$ref": "#/$defs/ProviderConfig" }, - "vllm": { "$ref": "#/$defs/ProviderConfig" }, - "gemini": { "$ref": "#/$defs/ProviderConfig" }, - "nvidia": { "$ref": "#/$defs/ProviderConfig" }, - "moonshot": { "$ref": "#/$defs/ProviderConfig" }, - "shengsuanyun": { "$ref": "#/$defs/ProviderConfig" }, - "deepseek": { "$ref": "#/$defs/ProviderConfig" }, - "github_copilot": { "$ref": "#/$defs/ProviderConfig" } + "anthropic": { + "allOf": [{ "$ref": "#/$defs/ProviderConfig" }], + "description": "Anthropic (Claude) API provider." + }, + "openai": { + "allOf": [{ "$ref": "#/$defs/ProviderConfig" }], + "description": "OpenAI API provider (GPT models)." + }, + "openrouter": { + "allOf": [{ "$ref": "#/$defs/ProviderConfig" }], + "description": "OpenRouter aggregator provider (routes requests to many different model backends)." + }, + "groq": { + "allOf": [{ "$ref": "#/$defs/ProviderConfig" }], + "description": "Groq hosted inference provider (fast LLM serving)." + }, + "zhipu": { + "allOf": [{ "$ref": "#/$defs/ProviderConfig" }], + "description": "Zhipu AI (智谱AI) provider (GLM model family)." + }, + "vllm": { + "allOf": [{ "$ref": "#/$defs/ProviderConfig" }], + "description": "vLLM-compatible OpenAI-style endpoint (typically self-hosted)." + }, + "gemini": { + "allOf": [{ "$ref": "#/$defs/ProviderConfig" }], + "description": "Google Gemini API provider." + }, + "nvidia": { + "allOf": [{ "$ref": "#/$defs/ProviderConfig" }], + "description": "NVIDIA hosted model API provider (where supported)." + }, + "moonshot": { + "allOf": [{ "$ref": "#/$defs/ProviderConfig" }], + "description": "Moonshot AI provider (Kimi)." + }, + "shengsuanyun": { + "allOf": [{ "$ref": "#/$defs/ProviderConfig" }], + "description": "Shengsuanyun (生数云) provider (where supported)." + }, + "deepseek": { + "allOf": [{ "$ref": "#/$defs/ProviderConfig" }], + "description": "DeepSeek API provider." + }, + "github_copilot": { + "allOf": [{ "$ref": "#/$defs/ProviderConfig" }], + "description": "GitHub Copilot provider (where supported by the integration)." + } } }, "ProviderConfig": { @@ -397,10 +446,17 @@ "WebToolsConfig": { "type": "object", "additionalProperties": false, + "description": "Web search providers used by the web tool.", "required": ["brave", "duckduckgo"], "properties": { - "brave": { "$ref": "#/$defs/BraveConfig" }, - "duckduckgo": { "$ref": "#/$defs/DuckDuckGoConfig" } + "brave": { + "allOf": [{ "$ref": "#/$defs/BraveConfig" }], + "description": "Brave Search API provider. Requires an API key." + }, + "duckduckgo": { + "allOf": [{ "$ref": "#/$defs/DuckDuckGoConfig" }], + "description": "DuckDuckGo search provider. No API key required." + } } }, "BraveConfig": { diff --git a/pkg/channels/webui.go b/pkg/channels/webui.go index 8e2e28258..090f007e9 100644 --- a/pkg/channels/webui.go +++ b/pkg/channels/webui.go @@ -317,9 +317,14 @@ func (c *WebUIChannel) handleAdminConfig(w http.ResponseWriter, r *http.Request) http.Error(w, err.Error(), http.StatusInternalServerError) return } + masked, err := maskConfigJSON(raw) + if err != nil { + http.Error(w, "Failed to mask config", http.StatusInternalServerError) + return + } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write(raw) + w.Write(masked) return } @@ -336,7 +341,18 @@ func (c *WebUIChannel) handleAdminConfig(w http.ResponseWriter, r *http.Request) http.Error(w, "Empty body", http.StatusBadRequest) return } - if err := c.configUpdateFn(body); err != nil { + + mergedRaw := body + if c.configReadFn != nil { + oldRaw, rerr := c.configReadFn() + if rerr == nil { + if out, merr := mergePreserveMaskedSecrets(oldRaw, body); merr == nil { + mergedRaw = out + } + } + } + + if err := c.configUpdateFn(mergedRaw); err != nil { msg := err.Error() lower := strings.ToLower(msg) if strings.Contains(lower, "config is not writable") { @@ -350,6 +366,106 @@ func (c *WebUIChannel) handleAdminConfig(w http.ResponseWriter, r *http.Request) w.Write([]byte("ok")) } +const maskedSentinel = "********" + +func maskStringPrefix8(s string) string { + if s == "" { + return s + } + n := 8 + if len(s) < n { + n = len(s) + } + return s[:n] + maskedSentinel +} + +func isSensitiveKey(k string) bool { + switch strings.ToLower(k) { + case "api_key", "token", "admin_token", "app_secret", "client_secret", "channel_secret", "channel_access_token", "access_token", "bot_token", "app_token", "encrypt_key", "verification_token": + return true + default: + return false + } +} + +func maskConfigJSON(raw []byte) ([]byte, error) { + var v any + if err := json.Unmarshal(raw, &v); err != nil { + return nil, err + } + maskAny(v) + return json.MarshalIndent(v, "", " ") +} + +func maskAny(v any) { + switch x := v.(type) { + case map[string]any: + for k, vv := range x { + if isSensitiveKey(k) { + if s, ok := vv.(string); ok && s != "" { + x[k] = maskStringPrefix8(s) + continue + } + } + maskAny(vv) + } + case []any: + for i := range x { + maskAny(x[i]) + } + default: + return + } +} + +func mergePreserveMaskedSecrets(oldRaw, newRaw []byte) ([]byte, error) { + var oldV any + var newV any + if err := json.Unmarshal(oldRaw, &oldV); err != nil { + return nil, err + } + if err := json.Unmarshal(newRaw, &newV); err != nil { + return nil, err + } + mergeAny(oldV, newV) + return json.MarshalIndent(newV, "", " ") +} + +func mergeAny(oldV any, newV any) { + oldMap, okOld := oldV.(map[string]any) + newMap, okNew := newV.(map[string]any) + if okOld && okNew { + for k, nv := range newMap { + ov, ok := oldMap[k] + if !ok { + continue + } + if isSensitiveKey(k) { + if s, ok := nv.(string); ok && strings.Contains(s, maskedSentinel) { + if os, ok := ov.(string); ok { + newMap[k] = os + continue + } + } + } + mergeAny(ov, nv) + } + return + } + + oldArr, okOldArr := oldV.([]any) + newArr, okNewArr := newV.([]any) + if okOldArr && okNewArr { + min := len(oldArr) + if len(newArr) < min { + min = len(newArr) + } + for i := 0; i < min; i++ { + mergeAny(oldArr[i], newArr[i]) + } + } +} + func (c *WebUIChannel) handleAdminSchema(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.Header().Set("Allow", http.MethodGet) diff --git a/pkg/channels/webui_admin_config_test.go b/pkg/channels/webui_admin_config_test.go new file mode 100644 index 000000000..0522cbc0c --- /dev/null +++ b/pkg/channels/webui_admin_config_test.go @@ -0,0 +1,96 @@ +package channels + +import ( + "encoding/json" + "testing" +) + +func TestMaskConfigJSON_MasksSensitiveValuesPrefix8(t *testing.T) { + in := []byte(`{ + "gateway": { + "token": "12345678ABCDEFGH", + "admin_token": "ABCDEFGH12345678" + }, + "providers": { + "openai": {"api_key": "sk-12345678ZZZZ", "api_base": ""} + }, + "channels": { + "telegram": {"token": "99999999TTTT"} + } + }`) + + out, err := maskConfigJSON(in) + if err != nil { + t.Fatalf("maskConfigJSON error: %v", err) + } + + var v map[string]any + if err := json.Unmarshal(out, &v); err != nil { + t.Fatalf("unmarshal masked output: %v", err) + } + + gateway := v["gateway"].(map[string]any) + if got := gateway["token"].(string); got != "12345678********" { + t.Fatalf("gateway.token = %q, want %q", got, "12345678********") + } + if got := gateway["admin_token"].(string); got != "ABCDEFGH********" { + t.Fatalf("gateway.admin_token = %q, want %q", got, "ABCDEFGH********") + } + + providers := v["providers"].(map[string]any) + openai := providers["openai"].(map[string]any) + if got := openai["api_key"].(string); got != "sk-12345********" { + t.Fatalf("providers.openai.api_key = %q, want %q", got, "sk-12345********") + } + + channels := v["channels"].(map[string]any) + tg := channels["telegram"].(map[string]any) + if got := tg["token"].(string); got != "99999999********" { + t.Fatalf("channels.telegram.token = %q, want %q", got, "99999999********") + } +} + +func TestMergePreserveMaskedSecrets_PreservesOnlyMaskedNotEmpty(t *testing.T) { + oldRaw := []byte(`{ + "gateway": {"token": "TOK_OLD_1234567890", "admin_token": "ADM_OLD_1234567890"}, + "providers": {"openai": {"api_key": "sk-OLD-1234567890", "api_base": ""}}, + "channels": {"telegram": {"token": "TG_OLD_1234567890"}} + }`) + + newRaw := []byte(`{ + "gateway": {"token": "TOK_OLD_********", "admin_token": ""}, + "providers": {"openai": {"api_key": "sk-OLD-1********", "api_base": ""}}, + "channels": {"telegram": {"token": "TG_NEW_123"}} + }`) + + merged, err := mergePreserveMaskedSecrets(oldRaw, newRaw) + if err != nil { + t.Fatalf("mergePreserveMaskedSecrets error: %v", err) + } + + var v map[string]any + if err := json.Unmarshal(merged, &v); err != nil { + t.Fatalf("unmarshal merged: %v", err) + } + + gateway := v["gateway"].(map[string]any) + if got := gateway["token"].(string); got != "TOK_OLD_1234567890" { + t.Fatalf("gateway.token = %q, want old preserved", got) + } + // empty string should not be treated as "keep"; it should remain empty (delete) + if got := gateway["admin_token"].(string); got != "" { + t.Fatalf("gateway.admin_token = %q, want empty string", got) + } + + providers := v["providers"].(map[string]any) + openai := providers["openai"].(map[string]any) + if got := openai["api_key"].(string); got != "sk-OLD-1234567890" { + t.Fatalf("providers.openai.api_key = %q, want old preserved", got) + } + + channels := v["channels"].(map[string]any) + tg := channels["telegram"].(map[string]any) + if got := tg["token"].(string); got != "TG_NEW_123" { + t.Fatalf("channels.telegram.token = %q, want new value", got) + } +}