From 2db8df8e381676b4588ebade5e9ec3784650aa45 Mon Sep 17 00:00:00 2001 From: Xvvln <3369759202@qq.com> Date: Tue, 24 Mar 2026 00:10:04 +0800 Subject: [PATCH 01/13] fix(security): harden management panel asset updater - Abort update when SHA256 digest mismatch is detected instead of logging a warning and proceeding (prevents MITM asset replacement) - Cap asset download size to 10 MB via io.LimitReader (defense-in-depth against OOM from oversized responses) - Add `auto-update-panel` config option (default: false) to make the periodic background updater opt-in; the panel is still downloaded on first access when missing, but no longer silently auto-updated every 3 hours unless explicitly enabled --- config.example.yaml | 4 ++++ internal/config/config.go | 3 +++ internal/managementasset/updater.go | 10 ++++++++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 3718a07a..c78a2d75 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -25,6 +25,10 @@ remote-management: # Disable the bundled management control panel asset download and HTTP route when true. disable-control-panel: false + # Enable automatic periodic background updates of the management panel from GitHub (default: false). + # When disabled, the panel is only downloaded on first access if missing, and never auto-updated afterward. + # auto-update-panel: false + # GitHub repository for the management control panel. Accepts a repository URL or releases API URL. panel-github-repository: "https://github.com/router-for-me/Cli-Proxy-API-Management-Center" diff --git a/internal/config/config.go b/internal/config/config.go index a11c741e..b1772b1f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -171,6 +171,9 @@ type RemoteManagement struct { SecretKey string `yaml:"secret-key"` // DisableControlPanel skips serving and syncing the bundled management UI when true. DisableControlPanel bool `yaml:"disable-control-panel"` + // AutoUpdatePanel enables automatic periodic background updates of the management panel asset from GitHub. + // When false (the default), the panel is only downloaded on first access if missing, and never auto-updated. + AutoUpdatePanel bool `yaml:"auto-update-panel"` // PanelGitHubRepository overrides the GitHub repository used to fetch the management panel asset. // Accepts either a repository URL (https://github.com/org/repo) or an API releases endpoint. PanelGitHubRepository string `yaml:"panel-github-repository"` diff --git a/internal/managementasset/updater.go b/internal/managementasset/updater.go index 7284b729..642dbf2f 100644 --- a/internal/managementasset/updater.go +++ b/internal/managementasset/updater.go @@ -31,6 +31,7 @@ const ( httpUserAgent = "CLIProxyAPI-management-updater" managementSyncMinInterval = 30 * time.Second updateCheckInterval = 3 * time.Hour + maxAssetDownloadSize = 10 << 20 // 10 MB safety limit for management asset downloads ) // ManagementFileName exposes the control panel asset filename. @@ -88,6 +89,10 @@ func runAutoUpdater(ctx context.Context) { log.Debug("management asset auto-updater skipped: control panel disabled") return } + if !cfg.RemoteManagement.AutoUpdatePanel { + log.Debug("management asset auto-updater skipped: auto-update-panel is disabled") + return + } configPath, _ := schedulerConfigPath.Load().(string) staticDir := StaticDir(configPath) @@ -259,7 +264,8 @@ func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL } if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) { - log.Warnf("remote digest mismatch for management asset: expected %s got %s", remoteHash, downloadedHash) + log.Errorf("management asset digest mismatch: expected %s got %s — aborting update for safety", remoteHash, downloadedHash) + return nil, nil } if err = atomicWriteFile(localPath, data); err != nil { @@ -392,7 +398,7 @@ func downloadAsset(ctx context.Context, client *http.Client, downloadURL string) return nil, "", fmt.Errorf("unexpected download status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } - data, err := io.ReadAll(resp.Body) + data, err := io.ReadAll(io.LimitReader(resp.Body, maxAssetDownloadSize)) if err != nil { return nil, "", fmt.Errorf("read download body: %w", err) } From 7333619f155a9064e30c71ce5e2258978ab0b325 Mon Sep 17 00:00:00 2001 From: Xvvln <3369759202@qq.com> Date: Tue, 24 Mar 2026 00:27:44 +0800 Subject: [PATCH 02/13] fix: reject oversized downloads instead of truncating; warn on unverified fallback - Read maxAssetDownloadSize+1 bytes and error if exceeded, preventing silent truncation that could write a broken management.html to disk - Log explicit warning when fallback URL is used without digest verification, so users are aware of the reduced security guarantee --- internal/managementasset/updater.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/managementasset/updater.go b/internal/managementasset/updater.go index 642dbf2f..473c1a91 100644 --- a/internal/managementasset/updater.go +++ b/internal/managementasset/updater.go @@ -288,6 +288,9 @@ func ensureFallbackManagementHTML(ctx context.Context, client *http.Client, loca return false } + log.Warnf("management asset downloaded from fallback URL without digest verification (hash=%s) — "+ + "consider setting auto-update-panel: true to receive verified updates from GitHub", downloadedHash) + if err = atomicWriteFile(localPath, data); err != nil { log.WithError(err).Warn("failed to persist fallback management control panel page") return false @@ -398,10 +401,13 @@ func downloadAsset(ctx context.Context, client *http.Client, downloadURL string) return nil, "", fmt.Errorf("unexpected download status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } - data, err := io.ReadAll(io.LimitReader(resp.Body, maxAssetDownloadSize)) + data, err := io.ReadAll(io.LimitReader(resp.Body, maxAssetDownloadSize+1)) if err != nil { return nil, "", fmt.Errorf("read download body: %w", err) } + if int64(len(data)) > maxAssetDownloadSize { + return nil, "", fmt.Errorf("download exceeds maximum allowed size of %d bytes", maxAssetDownloadSize) + } sum := sha256.Sum256(data) return data, hex.EncodeToString(sum[:]), nil From 000e4ceb4e6c8a5803db992f3049cf75f9a0143e Mon Sep 17 00:00:00 2001 From: GeJiaXiang <353358601@qq.com> Date: Tue, 24 Mar 2026 13:42:33 +0800 Subject: [PATCH 03/13] fix: map OpenAI system messages to Claude top-level system --- .../chat-completions/claude_openai_request.go | 11 +-- .../claude_openai_request_test.go | 68 +++++++++++++++++++ 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go index 112e286d..f6489894 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -165,29 +165,22 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream // Process messages and transform them to Claude Code format if messages := root.Get("messages"); messages.Exists() && messages.IsArray() { messageIndex := 0 - systemMessageIndex := -1 messages.ForEach(func(_, message gjson.Result) bool { role := message.Get("role").String() contentResult := message.Get("content") switch role { case "system": - if systemMessageIndex == -1 { - systemMsg := []byte(`{"role":"user","content":[]}`) - out, _ = sjson.SetRawBytes(out, "messages.-1", systemMsg) - systemMessageIndex = messageIndex - messageIndex++ - } if contentResult.Exists() && contentResult.Type == gjson.String && contentResult.String() != "" { textPart := []byte(`{"type":"text","text":""}`) textPart, _ = sjson.SetBytes(textPart, "text", contentResult.String()) - out, _ = sjson.SetRawBytes(out, fmt.Sprintf("messages.%d.content.-1", systemMessageIndex), textPart) + out, _ = sjson.SetRawBytes(out, "system.-1", textPart) } else if contentResult.Exists() && contentResult.IsArray() { contentResult.ForEach(func(_, part gjson.Result) bool { if part.Get("type").String() == "text" { textPart := []byte(`{"type":"text","text":""}`) textPart, _ = sjson.SetBytes(textPart, "text", part.Get("text").String()) - out, _ = sjson.SetRawBytes(out, fmt.Sprintf("messages.%d.content.-1", systemMessageIndex), textPart) + out, _ = sjson.SetRawBytes(out, "system.-1", textPart) } return true }) diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go index ed84661d..60e52980 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go @@ -135,3 +135,71 @@ func TestConvertOpenAIRequestToClaude_ToolResultURLImageOnly(t *testing.T) { t.Fatalf("Unexpected image URL: %q", got) } } + +func TestConvertOpenAIRequestToClaude_SystemRoleBecomesTopLevelSystem(t *testing.T) { + inputJSON := `{ + "model": "gpt-4.1", + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hello"} + ] + }` + + result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + + system := resultJSON.Get("system") + if !system.IsArray() { + t.Fatalf("Expected top-level system array, got %s", system.Raw) + } + if len(system.Array()) != 1 { + t.Fatalf("Expected 1 system block, got %d. System: %s", len(system.Array()), system.Raw) + } + if got := system.Get("0.type").String(); got != "text" { + t.Fatalf("Expected system block type %q, got %q", "text", got) + } + if got := system.Get("0.text").String(); got != "You are a helpful assistant." { + t.Fatalf("Expected system text %q, got %q", "You are a helpful assistant.", got) + } + + messages := resultJSON.Get("messages").Array() + if len(messages) != 1 { + t.Fatalf("Expected 1 non-system message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw) + } + if got := messages[0].Get("role").String(); got != "user" { + t.Fatalf("Expected remaining message role %q, got %q", "user", got) + } + if got := messages[0].Get("content.0.text").String(); got != "Hello" { + t.Fatalf("Expected user text %q, got %q", "Hello", got) + } +} + +func TestConvertOpenAIRequestToClaude_MultipleSystemMessagesMergedIntoTopLevelSystem(t *testing.T) { + inputJSON := `{ + "model": "gpt-4.1", + "messages": [ + {"role": "system", "content": "Rule 1"}, + {"role": "system", "content": [{"type": "text", "text": "Rule 2"}]}, + {"role": "user", "content": "Hello"} + ] + }` + + result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + + system := resultJSON.Get("system").Array() + if len(system) != 2 { + t.Fatalf("Expected 2 system blocks, got %d. System: %s", len(system), resultJSON.Get("system").Raw) + } + if got := system[0].Get("text").String(); got != "Rule 1" { + t.Fatalf("Expected first system text %q, got %q", "Rule 1", got) + } + if got := system[1].Get("text").String(); got != "Rule 2" { + t.Fatalf("Expected second system text %q, got %q", "Rule 2", got) + } + + messages := resultJSON.Get("messages").Array() + if len(messages) != 1 { + t.Fatalf("Expected 1 non-system message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw) + } +} From 8c67b3ae648b736eb8794ff9fe925aaea802dd74 Mon Sep 17 00:00:00 2001 From: GeJiaXiang <353358601@qq.com> Date: Tue, 24 Mar 2026 13:47:52 +0800 Subject: [PATCH 04/13] test: verify remaining user message after system merge --- .../openai/chat-completions/claude_openai_request_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go index 60e52980..ef8f0036 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go @@ -202,4 +202,10 @@ func TestConvertOpenAIRequestToClaude_MultipleSystemMessagesMergedIntoTopLevelSy if len(messages) != 1 { t.Fatalf("Expected 1 non-system message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw) } + if got := messages[0].Get("role").String(); got != "user" { + t.Fatalf("Expected remaining message role %q, got %q", "user", got) + } + if got := messages[0].Get("content.0.text").String(); got != "Hello" { + t.Fatalf("Expected user text %q, got %q", "Hello", got) + } } From 09c92aa0b5290e43fbcd537ed7e6e87824443c0e Mon Sep 17 00:00:00 2001 From: GeJiaXiang <353358601@qq.com> Date: Tue, 24 Mar 2026 13:54:25 +0800 Subject: [PATCH 05/13] fix: keep a fallback turn for system-only Claude inputs --- .../chat-completions/claude_openai_request.go | 10 ++++++ .../claude_openai_request_test.go | 34 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go index f6489894..e9d8d35b 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go @@ -262,6 +262,16 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream } return true }) + + // Preserve a minimal conversational turn for system-only inputs. + // Claude payloads with top-level system instructions but no messages are risky for downstream validation. + if messageIndex == 0 { + system := gjson.GetBytes(out, "system") + if system.Exists() && system.IsArray() && len(system.Array()) > 0 { + fallbackMsg := []byte(`{"role":"user","content":[{"type":"text","text":""}]}`) + out, _ = sjson.SetRawBytes(out, "messages.-1", fallbackMsg) + } + } } // Tools mapping: OpenAI tools -> Claude Code tools diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go index ef8f0036..ead08d72 100644 --- a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go +++ b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go @@ -209,3 +209,37 @@ func TestConvertOpenAIRequestToClaude_MultipleSystemMessagesMergedIntoTopLevelSy t.Fatalf("Expected user text %q, got %q", "Hello", got) } } + +func TestConvertOpenAIRequestToClaude_SystemOnlyInputKeepsFallbackUserMessage(t *testing.T) { + inputJSON := `{ + "model": "gpt-4.1", + "messages": [ + {"role": "system", "content": "You are a helpful assistant."} + ] + }` + + result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false) + resultJSON := gjson.ParseBytes(result) + + system := resultJSON.Get("system").Array() + if len(system) != 1 { + t.Fatalf("Expected 1 system block, got %d. System: %s", len(system), resultJSON.Get("system").Raw) + } + if got := system[0].Get("text").String(); got != "You are a helpful assistant." { + t.Fatalf("Expected system text %q, got %q", "You are a helpful assistant.", got) + } + + messages := resultJSON.Get("messages").Array() + if len(messages) != 1 { + t.Fatalf("Expected 1 fallback message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw) + } + if got := messages[0].Get("role").String(); got != "user" { + t.Fatalf("Expected fallback message role %q, got %q", "user", got) + } + if got := messages[0].Get("content.0.type").String(); got != "text" { + t.Fatalf("Expected fallback content type %q, got %q", "text", got) + } + if got := messages[0].Get("content.0.text").String(); got != "" { + t.Fatalf("Expected fallback text %q, got %q", "", got) + } +} From d312422ab4dc9d793fd796c2bd9cbac051c6e3c9 Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 24 Mar 2026 16:49:04 +0800 Subject: [PATCH 06/13] build: add freebsd support to releases --- .goreleaser.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.goreleaser.yml b/.goreleaser.yml index df828102..f8bebfc1 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -8,6 +8,7 @@ builds: - linux - windows - darwin + - freebsd goarch: - amd64 - arm64 From 1e6bc81cfdfc2ec5b6d13195ee239c0bf3a7b17c Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 25 Mar 2026 10:31:44 +0800 Subject: [PATCH 07/13] refactor(config): replace `auto-update-panel` with `disable-auto-update-panel` for clarity --- config.example.yaml | 6 +-- internal/config/config.go | 6 +-- internal/managementasset/updater.go | 8 ++-- internal/watcher/diff/config_diff.go | 3 ++ internal/watcher/diff/config_diff_test.go | 46 +++++++++++++---------- 5 files changed, 40 insertions(+), 29 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 9ef875af..42867ecb 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -25,9 +25,9 @@ remote-management: # Disable the bundled management control panel asset download and HTTP route when true. disable-control-panel: false - # Enable automatic periodic background updates of the management panel from GitHub (default: false). - # When disabled, the panel is only downloaded on first access if missing, and never auto-updated afterward. - # auto-update-panel: false + # Disable automatic periodic background updates of the management panel from GitHub (default: false). + # When enabled, the panel is only downloaded on first access if missing, and never auto-updated afterward. + # disable-auto-update-panel: false # GitHub repository for the management control panel. Accepts a repository URL or releases API URL. panel-github-repository: "https://github.com/router-for-me/Cli-Proxy-API-Management-Center" diff --git a/internal/config/config.go b/internal/config/config.go index 1b06392b..c4156e97 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -178,9 +178,9 @@ type RemoteManagement struct { SecretKey string `yaml:"secret-key"` // DisableControlPanel skips serving and syncing the bundled management UI when true. DisableControlPanel bool `yaml:"disable-control-panel"` - // AutoUpdatePanel enables automatic periodic background updates of the management panel asset from GitHub. - // When false (the default), the panel is only downloaded on first access if missing, and never auto-updated. - AutoUpdatePanel bool `yaml:"auto-update-panel"` + // DisableAutoUpdatePanel disables automatic periodic background updates of the management panel asset from GitHub. + // When false (the default), the background updater remains enabled; when true, the panel is only downloaded on first access if missing. + DisableAutoUpdatePanel bool `yaml:"disable-auto-update-panel"` // PanelGitHubRepository overrides the GitHub repository used to fetch the management panel asset. // Accepts either a repository URL (https://github.com/org/repo) or an API releases endpoint. PanelGitHubRepository string `yaml:"panel-github-repository"` diff --git a/internal/managementasset/updater.go b/internal/managementasset/updater.go index 473c1a91..ae2bc819 100644 --- a/internal/managementasset/updater.go +++ b/internal/managementasset/updater.go @@ -31,7 +31,7 @@ const ( httpUserAgent = "CLIProxyAPI-management-updater" managementSyncMinInterval = 30 * time.Second updateCheckInterval = 3 * time.Hour - maxAssetDownloadSize = 10 << 20 // 10 MB safety limit for management asset downloads + maxAssetDownloadSize = 50 << 20 // 10 MB safety limit for management asset downloads ) // ManagementFileName exposes the control panel asset filename. @@ -89,8 +89,8 @@ func runAutoUpdater(ctx context.Context) { log.Debug("management asset auto-updater skipped: control panel disabled") return } - if !cfg.RemoteManagement.AutoUpdatePanel { - log.Debug("management asset auto-updater skipped: auto-update-panel is disabled") + if cfg.RemoteManagement.DisableAutoUpdatePanel { + log.Debug("management asset auto-updater skipped: disable-auto-update-panel is enabled") return } @@ -289,7 +289,7 @@ func ensureFallbackManagementHTML(ctx context.Context, client *http.Client, loca } log.Warnf("management asset downloaded from fallback URL without digest verification (hash=%s) — "+ - "consider setting auto-update-panel: true to receive verified updates from GitHub", downloadedHash) + "enable verified GitHub updates by keeping disable-auto-update-panel set to false", downloadedHash) if err = atomicWriteFile(localPath, data); err != nil { log.WithError(err).Warn("failed to persist fallback management control panel page") diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go index 7997f04e..fccdaf8d 100644 --- a/internal/watcher/diff/config_diff.go +++ b/internal/watcher/diff/config_diff.go @@ -256,6 +256,9 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if oldCfg.RemoteManagement.DisableControlPanel != newCfg.RemoteManagement.DisableControlPanel { changes = append(changes, fmt.Sprintf("remote-management.disable-control-panel: %t -> %t", oldCfg.RemoteManagement.DisableControlPanel, newCfg.RemoteManagement.DisableControlPanel)) } + if oldCfg.RemoteManagement.DisableAutoUpdatePanel != newCfg.RemoteManagement.DisableAutoUpdatePanel { + changes = append(changes, fmt.Sprintf("remote-management.disable-auto-update-panel: %t -> %t", oldCfg.RemoteManagement.DisableAutoUpdatePanel, newCfg.RemoteManagement.DisableAutoUpdatePanel)) + } oldPanelRepo := strings.TrimSpace(oldCfg.RemoteManagement.PanelGitHubRepository) newPanelRepo := strings.TrimSpace(newCfg.RemoteManagement.PanelGitHubRepository) if oldPanelRepo != newPanelRepo { diff --git a/internal/watcher/diff/config_diff_test.go b/internal/watcher/diff/config_diff_test.go index f35ceeea..c7b73f11 100644 --- a/internal/watcher/diff/config_diff_test.go +++ b/internal/watcher/diff/config_diff_test.go @@ -20,10 +20,11 @@ func TestBuildConfigChangeDetails(t *testing.T) { RestrictManagementToLocalhost: false, }, RemoteManagement: config.RemoteManagement{ - AllowRemote: false, - SecretKey: "old", - DisableControlPanel: false, - PanelGitHubRepository: "repo-old", + AllowRemote: false, + SecretKey: "old", + DisableControlPanel: false, + DisableAutoUpdatePanel: false, + PanelGitHubRepository: "repo-old", }, OAuthExcludedModels: map[string][]string{ "providerA": {"m1"}, @@ -54,10 +55,11 @@ func TestBuildConfigChangeDetails(t *testing.T) { }, }, RemoteManagement: config.RemoteManagement{ - AllowRemote: true, - SecretKey: "new", - DisableControlPanel: true, - PanelGitHubRepository: "repo-new", + AllowRemote: true, + SecretKey: "new", + DisableControlPanel: true, + DisableAutoUpdatePanel: true, + PanelGitHubRepository: "repo-new", }, OAuthExcludedModels: map[string][]string{ "providerA": {"m1", "m2"}, @@ -88,6 +90,7 @@ func TestBuildConfigChangeDetails(t *testing.T) { expectContains(t, details, "ampcode.upstream-url: http://old-upstream -> http://new-upstream") expectContains(t, details, "ampcode.model-mappings: updated (1 -> 2 entries)") expectContains(t, details, "remote-management.allow-remote: false -> true") + expectContains(t, details, "remote-management.disable-auto-update-panel: false -> true") expectContains(t, details, "remote-management.secret-key: updated") expectContains(t, details, "oauth-excluded-models[providera]: updated (1 -> 2 entries)") expectContains(t, details, "oauth-excluded-models[providerb]: added (1 entries)") @@ -265,9 +268,10 @@ func TestBuildConfigChangeDetails_FlagsAndKeys(t *testing.T) { ModelMappings: []config.AmpModelMapping{{From: "a", To: "b"}}, }, RemoteManagement: config.RemoteManagement{ - DisableControlPanel: true, - PanelGitHubRepository: "new/repo", - SecretKey: "", + DisableControlPanel: true, + DisableAutoUpdatePanel: true, + PanelGitHubRepository: "new/repo", + SecretKey: "", }, SDKConfig: sdkconfig.SDKConfig{ RequestLog: true, @@ -299,6 +303,7 @@ func TestBuildConfigChangeDetails_FlagsAndKeys(t *testing.T) { expectContains(t, details, "ampcode.restrict-management-to-localhost: false -> true") expectContains(t, details, "ampcode.upstream-api-key: removed") expectContains(t, details, "remote-management.disable-control-panel: false -> true") + expectContains(t, details, "remote-management.disable-auto-update-panel: false -> true") expectContains(t, details, "remote-management.panel-github-repository: old/repo -> new/repo") expectContains(t, details, "remote-management.secret-key: deleted") } @@ -336,10 +341,11 @@ func TestBuildConfigChangeDetails_AllBranches(t *testing.T) { ForceModelMappings: false, }, RemoteManagement: config.RemoteManagement{ - AllowRemote: false, - DisableControlPanel: false, - PanelGitHubRepository: "old/repo", - SecretKey: "old", + AllowRemote: false, + DisableControlPanel: false, + DisableAutoUpdatePanel: false, + PanelGitHubRepository: "old/repo", + SecretKey: "old", }, SDKConfig: sdkconfig.SDKConfig{ RequestLog: false, @@ -389,10 +395,11 @@ func TestBuildConfigChangeDetails_AllBranches(t *testing.T) { ForceModelMappings: true, }, RemoteManagement: config.RemoteManagement{ - AllowRemote: true, - DisableControlPanel: true, - PanelGitHubRepository: "new/repo", - SecretKey: "", + AllowRemote: true, + DisableControlPanel: true, + DisableAutoUpdatePanel: true, + PanelGitHubRepository: "new/repo", + SecretKey: "", }, SDKConfig: sdkconfig.SDKConfig{ RequestLog: true, @@ -460,6 +467,7 @@ func TestBuildConfigChangeDetails_AllBranches(t *testing.T) { expectContains(t, changes, "oauth-excluded-models[p2]: added (1 entries)") expectContains(t, changes, "remote-management.allow-remote: false -> true") expectContains(t, changes, "remote-management.disable-control-panel: false -> true") + expectContains(t, changes, "remote-management.disable-auto-update-panel: false -> true") expectContains(t, changes, "remote-management.panel-github-repository: old/repo -> new/repo") expectContains(t, changes, "remote-management.secret-key: deleted") expectContains(t, changes, "openai-compatibility:") From d42b5d4e7810efa1a2574b6dc64ad8a965725565 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 27 Mar 2026 11:46:21 +0800 Subject: [PATCH 08/13] docs(readme): update QQ group information in Chinese README --- README_CN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README_CN.md b/README_CN.md index 671bd992..6301e403 100644 --- a/README_CN.md +++ b/README_CN.md @@ -183,7 +183,7 @@ OmniRoute 是一个面向多供应商大语言模型的 AI 网关:它提供兼 ## 写给所有中国网友的 -QQ 群:188637136 +QQ 群:188637136(满)、1081218164 或 From 1821bf70511ae1f0b4cd3d5d73247ffe051288df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD=E9=87=91?= <84262602@qq.com> Date: Fri, 27 Mar 2026 17:39:29 +0800 Subject: [PATCH 09/13] docs: add BmoPlus sponsorship banners to READMEs --- README.md | 4 ++++ README_CN.md | 5 +++++ README_JA.md | 4 ++++ assets/bmoplus.png | Bin 0 -> 28894 bytes 4 files changed, 13 insertions(+) create mode 100644 assets/bmoplus.png diff --git a/README.md b/README.md index 25e0090e..5f2bcd88 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB AICodeMirror Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CLIProxyAPI users: register via this link to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off! + +BmoPlus +Huge thanks to BmoPlus for sponsoring this project! BmoPlus is a highly reliable AI account provider built strictly for heavy AI users and developers. They offer rock-solid, ready-to-use accounts and official top-up services for ChatGPT Plus / ChatGPT Pro (Full Warranty) / Claude Pro / Super Grok / Gemini Pro. By registering and ordering through BmoPlus - Premium AI Accounts & Top-ups, users can unlock the mind-blowing rate of 10% of the official GPT subscription price (90% OFF)! + diff --git a/README_CN.md b/README_CN.md index 6301e403..8fa2b041 100644 --- a/README_CN.md +++ b/README_CN.md @@ -30,10 +30,15 @@ GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元 AICodeMirror 感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。 Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CLIProxyAPI 的用户提供了特别福利,通过此链接注册的用户,可享受首充8折,企业客户最高可享 7.5 折! + +BmoPlus +感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus/ChatGPT Pro(全程质保)/Claude Pro/Super Grok/Gemini Pro 的官方代充&成品账号。 通过BmoPlus AI成品号专卖/代充注册下单的用户,可享GPT 官网订阅一折 的震撼价格! + + ## 功能特性 - 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex 兼容的 API 端点 diff --git a/README_JA.md b/README_JA.md index 4dbd36bb..8d5cb2bc 100644 --- a/README_JA.md +++ b/README_JA.md @@ -30,6 +30,10 @@ GLM CODING PLANを10%割引で取得:https://z.ai/subscribe?ic=8JVLJQFSKB AICodeMirror AICodeMirrorのスポンサーシップに感謝します!AICodeMirrorはClaude Code / Codex / Gemini CLI向けの公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時接続、迅速な請求書発行、24時間365日の専任技術サポートを備えています。Claude Code / Codex / Geminiの公式チャネルが元の価格の38% / 2% / 9%で利用でき、チャージ時にはさらに割引があります!CLIProxyAPIユーザー向けの特別特典:こちらのリンクから登録すると、初回チャージが20%割引になり、エンタープライズのお客様は最大25%割引を受けられます! + +BmoPlus +本プロジェクトにご支援いただいた BmoPlus に感謝いたします!BmoPlusは、AIサブスクリプションのヘビーユーザー向けに特化した信頼性の高いAIアカウントサービスプロバイダーであり、安定した ChatGPT Plus / ChatGPT Pro (完全保証) / Claude Pro / Super Grok / Gemini Pro の公式代行チャージおよび即納アカウントを提供しています。こちらのBmoPlus AIアカウント専門店/代行チャージ経由でご登録・ご注文いただいたユーザー様は、GPTを 公式サイト価格の約1割(90% OFF) という驚異的な価格でご利用いただけます! + diff --git a/assets/bmoplus.png b/assets/bmoplus.png new file mode 100644 index 0000000000000000000000000000000000000000..27b8df41f04c95fcf7a061fbd5a650f28e38ab39 GIT binary patch literal 28894 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 R`cDsSulWdeCDYQcSpp;_gi zN9keK9$A(~3o4>NVn;atFRLbFDH-lds)QT_Ud{)>V4Ug_i`7M+q_ysUi8G(sts<1U zVE%jgKlXz2+o|~f%>a8UA5`}Hf3LGJ*#6%=yuV@o`~B#v9jbxL|Mw7R^hWRhkDFuO a0OtF_KQ^995}|>GFbXm%(m<&%!T$&BzpDrU literal 0 HcmV?d00001 From 6b45d311ecd2851d6e80bda8c70d0bbc21fd6d95 Mon Sep 17 00:00:00 2001 From: B3o <29422676+B3o@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:01:35 +0800 Subject: [PATCH 10/13] add BmoPlus sponsorship banners to READMEs --- README_CN.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README_CN.md b/README_CN.md index 8fa2b041..6945e0b3 100644 --- a/README_CN.md +++ b/README_CN.md @@ -38,7 +38,6 @@ GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元 - ## 功能特性 - 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex 兼容的 API 端点 From 70c90687fd6570b6f6f4b0b30a32c880a6ff1853 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 27 Mar 2026 20:49:43 +0800 Subject: [PATCH 11/13] docs(readme): fix formatting in BmoPlus sponsorship section of Chinese README --- README_CN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README_CN.md b/README_CN.md index 6945e0b3..95885ecc 100644 --- a/README_CN.md +++ b/README_CN.md @@ -32,7 +32,7 @@ GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元 BmoPlus -感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus/ChatGPT Pro(全程质保)/Claude Pro/Super Grok/Gemini Pro 的官方代充&成品账号。 通过BmoPlus AI成品号专卖/代充注册下单的用户,可享GPT 官网订阅一折 的震撼价格! +感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus / ChatGPT Pro(全程质保) / Claude Pro / Super Grok /Gemini Pro 的官方代充&成品账号。 通过BmoPlus AI成品号专卖/代充注册下单的用户,可享GPT 官网订阅一折 的震撼价格! From 7dccc7ba2f5fb84508211f0f941b06d599facb65 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 27 Mar 2026 20:52:14 +0800 Subject: [PATCH 12/13] docs(readme): remove redundant whitespace in BmoPlus sponsorship section of Chinese README --- README_CN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README_CN.md b/README_CN.md index 95885ecc..9b47ef3e 100644 --- a/README_CN.md +++ b/README_CN.md @@ -32,7 +32,7 @@ GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元 BmoPlus -感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus / ChatGPT Pro(全程质保) / Claude Pro / Super Grok /Gemini Pro 的官方代充&成品账号。 通过BmoPlus AI成品号专卖/代充注册下单的用户,可享GPT 官网订阅一折 的震撼价格! +感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus / ChatGPT Pro(全程质保) / Claude Pro / Super Grok / Gemini Pro 的官方代充&成品账号。 通过BmoPlus AI成品号专卖/代充注册下单的用户,可享GPT 官网订阅一折 的震撼价格! From 10b824fcac9c8e68100d51e765989647782e656a Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 28 Mar 2026 04:48:23 +0800 Subject: [PATCH 13/13] fix(security): validate auth file names to prevent unsafe input --- .../api/handlers/management/auth_files.go | 23 +++++-- .../management/auth_files_download_test.go | 62 +++++++++++++++++++ .../auth_files_download_windows_test.go | 51 +++++++++++++++ 3 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 internal/api/handlers/management/auth_files_download_test.go create mode 100644 internal/api/handlers/management/auth_files_download_windows_test.go diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index b9bdc983..2e1f02bf 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -541,10 +541,23 @@ func isRuntimeOnlyAuth(auth *coreauth.Auth) bool { return strings.EqualFold(strings.TrimSpace(auth.Attributes["runtime_only"]), "true") } +func isUnsafeAuthFileName(name string) bool { + if strings.TrimSpace(name) == "" { + return true + } + if strings.ContainsAny(name, "/\\") { + return true + } + if filepath.VolumeName(name) != "" { + return true + } + return false +} + // Download single auth file by name func (h *Handler) DownloadAuthFile(c *gin.Context) { - name := c.Query("name") - if name == "" || strings.Contains(name, string(os.PathSeparator)) { + name := strings.TrimSpace(c.Query("name")) + if isUnsafeAuthFileName(name) { c.JSON(400, gin.H{"error": "invalid name"}) return } @@ -626,8 +639,8 @@ func (h *Handler) UploadAuthFile(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "no files uploaded"}) return } - name := c.Query("name") - if name == "" || strings.Contains(name, string(os.PathSeparator)) { + name := strings.TrimSpace(c.Query("name")) + if isUnsafeAuthFileName(name) { c.JSON(400, gin.H{"error": "invalid name"}) return } @@ -860,7 +873,7 @@ func uniqueAuthFileNames(names []string) []string { func (h *Handler) deleteAuthFileByName(ctx context.Context, name string) (string, int, error) { name = strings.TrimSpace(name) - if name == "" || strings.Contains(name, string(os.PathSeparator)) { + if isUnsafeAuthFileName(name) { return "", http.StatusBadRequest, fmt.Errorf("invalid name") } diff --git a/internal/api/handlers/management/auth_files_download_test.go b/internal/api/handlers/management/auth_files_download_test.go new file mode 100644 index 00000000..a2a20d30 --- /dev/null +++ b/internal/api/handlers/management/auth_files_download_test.go @@ -0,0 +1,62 @@ +package management + +import ( + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" +) + +func TestDownloadAuthFile_ReturnsFile(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "") + gin.SetMode(gin.TestMode) + + authDir := t.TempDir() + fileName := "download-user.json" + expected := []byte(`{"type":"codex"}`) + if err := os.WriteFile(filepath.Join(authDir, fileName), expected, 0o600); err != nil { + t.Fatalf("failed to write auth file: %v", err) + } + + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil) + + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/auth-files/download?name="+url.QueryEscape(fileName), nil) + h.DownloadAuthFile(ctx) + + if rec.Code != http.StatusOK { + t.Fatalf("expected download status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String()) + } + if got := rec.Body.Bytes(); string(got) != string(expected) { + t.Fatalf("unexpected download content: %q", string(got)) + } +} + +func TestDownloadAuthFile_RejectsPathSeparators(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "") + gin.SetMode(gin.TestMode) + + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, nil) + + for _, name := range []string{ + "../external/secret.json", + `..\\external\\secret.json`, + "nested/secret.json", + `nested\\secret.json`, + } { + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/auth-files/download?name="+url.QueryEscape(name), nil) + h.DownloadAuthFile(ctx) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected %d for name %q, got %d with body %s", http.StatusBadRequest, name, rec.Code, rec.Body.String()) + } + } +} diff --git a/internal/api/handlers/management/auth_files_download_windows_test.go b/internal/api/handlers/management/auth_files_download_windows_test.go new file mode 100644 index 00000000..8c174ccf --- /dev/null +++ b/internal/api/handlers/management/auth_files_download_windows_test.go @@ -0,0 +1,51 @@ +//go:build windows + +package management + +import ( + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" +) + +func TestDownloadAuthFile_PreventsWindowsSlashTraversal(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "") + gin.SetMode(gin.TestMode) + + tempDir := t.TempDir() + authDir := filepath.Join(tempDir, "auth") + externalDir := filepath.Join(tempDir, "external") + if err := os.MkdirAll(authDir, 0o700); err != nil { + t.Fatalf("failed to create auth dir: %v", err) + } + if err := os.MkdirAll(externalDir, 0o700); err != nil { + t.Fatalf("failed to create external dir: %v", err) + } + + secretName := "secret.json" + secretPath := filepath.Join(externalDir, secretName) + if err := os.WriteFile(secretPath, []byte(`{"secret":true}`), 0o600); err != nil { + t.Fatalf("failed to write external file: %v", err) + } + + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil) + + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest( + http.MethodGet, + "/v0/management/auth-files/download?name="+url.QueryEscape("../external/"+secretName), + nil, + ) + h.DownloadAuthFile(ctx) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d with body %s", http.StatusBadRequest, rec.Code, rec.Body.String()) + } +}