masked sensitive

improved schema
This commit is contained in:
Stefan Rinke 2026-02-16 09:00:08 +01:00
parent 84815818f7
commit 83682e203d
3 changed files with 284 additions and 16 deletions

View file

@ -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": {

View file

@ -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)

View file

@ -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)
}
}