From f187f08ae366a52b2f95d6317e542f9a87fd1559 Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Sun, 12 Jul 2026 11:39:54 +0800 Subject: [PATCH] fix(grok): harden OAuth routing and CLI version guard --- backend/go.mod | 2 +- backend/internal/repository/http_upstream.go | 14 ++-- .../internal/repository/http_upstream_test.go | 41 ++++++++++++ backend/internal/service/account.go | 26 +++++++- .../internal/service/account_base_url_test.go | 66 +++++++++++++++++++ 5 files changed, 142 insertions(+), 7 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index a06f06437..64b5c30d9 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -44,6 +44,7 @@ require ( go.uber.org/zap v1.24.0 golang.org/x/crypto v0.51.0 golang.org/x/image v0.39.0 + golang.org/x/mod v0.35.0 golang.org/x/net v0.55.0 golang.org/x/sync v0.20.0 golang.org/x/term v0.43.0 @@ -176,7 +177,6 @@ require ( go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/mod v0.35.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/tools v0.44.0 // indirect diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index ace6fc1a8..bb079b078 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -14,7 +14,6 @@ import ( "net/http" "net/url" "os" - "regexp" "strings" "sync" "sync/atomic" @@ -29,6 +28,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" + "golang.org/x/mod/semver" ) // 默认配置常量 @@ -68,8 +68,6 @@ const ( grokCLIVersionOverride = "XAI_GROK_CLI_VERSION" ) -var grokCLIVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?$`) - const ( upstreamProtocolModeDefault = "default" upstreamProtocolModeOpenAIH1 = "openai_h1" @@ -275,7 +273,7 @@ func applyGrokCLIProxyHeaders(req *http.Request) { req.Header = make(http.Header) } version := strings.TrimSpace(os.Getenv(grokCLIVersionOverride)) - if !grokCLIVersionPattern.MatchString(version) { + if !isSupportedGrokCLIVersion(version) { version = grokCLIStableVersion } req.Header.Set("X-XAI-Token-Auth", "xai-grok-cli") @@ -283,6 +281,14 @@ func applyGrokCLIProxyHeaders(req *http.Request) { req.Header.Set("User-Agent", "xai-grok-workspace/"+version) } +func isSupportedGrokCLIVersion(version string) bool { + canonical := "v" + version + minimum := "v" + grokCLIStableVersion + return semver.IsValid(canonical) && + semver.Canonical(canonical) == canonical && + semver.Compare(canonical, minimum) >= 0 +} + // acquireClientWithTLS 获取或创建带 TLS 指纹的客户端 func (s *httpUpstreamService) acquireClientWithTLS(proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile, upstreamProfile service.HTTPUpstreamProfile) (*upstreamClientEntry, error) { return s.getClientEntryWithTLS(proxyURL, accountID, accountConcurrency, profile, upstreamProfile, true, true) diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go index a632dd662..90cbd15ae 100644 --- a/backend/internal/repository/http_upstream_test.go +++ b/backend/internal/repository/http_upstream_test.go @@ -51,6 +51,47 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { require.Empty(t, req.Header.Get("X-Injected")) }) + t.Run("rejects an override below the supported minimum", func(t *testing.T) { + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.92") + req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) + require.NoError(t, err) + + applyGrokCLIProxyHeaders(req) + + require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version")) + require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent")) + }) + + t.Run("rejects a prerelease override at the minimum version", func(t *testing.T) { + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.93-beta.1") + req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) + require.NoError(t, err) + + applyGrokCLIProxyHeaders(req) + + require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version")) + require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent")) + }) + + for _, version := range []string{ + "0.2.093", + "0.2.94-alpha..1", + "0.3", + "1", + "0.2.95+build.1", + } { + t.Run("rejects invalid semver "+version, func(t *testing.T) { + t.Setenv("XAI_GROK_CLI_VERSION", version) + req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) + require.NoError(t, err) + + applyGrokCLIProxyHeaders(req) + + require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version")) + require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent")) + }) + } + t.Run("leaves direct xAI API requests unchanged", func(t *testing.T) { t.Setenv("XAI_GROK_CLI_VERSION", "0.2.95") req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 52411d4c6..62cb5a2e6 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -6,6 +6,7 @@ import ( "errors" "hash/fnv" "log/slog" + "net/url" "reflect" "sort" "strconv" @@ -1256,8 +1257,7 @@ func (a *Account) GetGrokBaseURL() string { } baseURL := a.GetCredential("base_url") if a.IsGrokOAuth() { - normalizedBaseURL := strings.TrimRight(strings.TrimSpace(baseURL), "/") - if normalizedBaseURL == "" || strings.EqualFold(normalizedBaseURL, xai.DefaultBaseURL) { + if strings.TrimSpace(baseURL) == "" || isOfficialGrokAPIBaseURL(baseURL) { return xai.DefaultCLIBaseURL } } @@ -1267,6 +1267,28 @@ func (a *Account) GetGrokBaseURL() string { return xai.DefaultBaseURL } +func isOfficialGrokAPIBaseURL(raw string) bool { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed == nil || parsed.Opaque != "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return false + } + defaultURL, err := url.Parse(xai.DefaultBaseURL) + if err != nil { + return false + } + if !strings.EqualFold(parsed.Scheme, defaultURL.Scheme) || !strings.EqualFold(parsed.Hostname(), defaultURL.Hostname()) { + return false + } + if port := parsed.Port(); port != "" { + portNumber, err := strconv.Atoi(port) + if err != nil || portNumber != 443 { + return false + } + } + path := strings.TrimRight(parsed.Path, "/") + return path == "" || path == strings.TrimRight(defaultURL.Path, "/") +} + func (a *Account) GetGrokAccessToken() string { if !a.IsGrok() { return "" diff --git a/backend/internal/service/account_base_url_test.go b/backend/internal/service/account_base_url_test.go index cf3655dbb..0ffaa21ae 100644 --- a/backend/internal/service/account_base_url_test.go +++ b/backend/internal/service/account_base_url_test.go @@ -199,6 +199,72 @@ func TestGetGrokBaseURLUsesSubscriptionProxyForOAuth(t *testing.T) { }, expected: xai.DefaultCLIBaseURL, }, + { + name: "oauth legacy API root is migrated at runtime", + account: Account{ + Type: AccountTypeOAuth, + Platform: PlatformGrok, + Credentials: map[string]any{ + "base_url": "https://api.x.ai", + }, + }, + expected: xai.DefaultCLIBaseURL, + }, + { + name: "oauth legacy API root with canonical HTTPS port is migrated at runtime", + account: Account{ + Type: AccountTypeOAuth, + Platform: PlatformGrok, + Credentials: map[string]any{ + "base_url": "HTTPS://API.X.AI:443/", + }, + }, + expected: xai.DefaultCLIBaseURL, + }, + { + name: "oauth legacy API canonical port with leading zeroes is migrated at runtime", + account: Account{ + Type: AccountTypeOAuth, + Platform: PlatformGrok, + Credentials: map[string]any{ + "base_url": "https://api.x.ai:0443/v1", + }, + }, + expected: xai.DefaultCLIBaseURL, + }, + { + name: "oauth legacy API encoded version path is migrated at runtime", + account: Account{ + Type: AccountTypeOAuth, + Platform: PlatformGrok, + Credentials: map[string]any{ + "base_url": "https://api.x.ai/%76%31", + }, + }, + expected: xai.DefaultCLIBaseURL, + }, + { + name: "oauth legacy API encoded trailing slash is migrated at runtime", + account: Account{ + Type: AccountTypeOAuth, + Platform: PlatformGrok, + Credentials: map[string]any{ + "base_url": "https://api.x.ai/v1%2F", + }, + }, + expected: xai.DefaultCLIBaseURL, + }, + { + name: "oauth non-default API port remains an explicit override", + account: Account{ + Type: AccountTypeOAuth, + Platform: PlatformGrok, + Credentials: map[string]any{ + "base_url": "https://api.x.ai:8443/v1", + }, + }, + expected: "https://api.x.ai:8443/v1", + }, { name: "oauth explicit custom base_url remains supported", account: Account{