From 9eb120dd40509d17df17bb61ceef22554d9ca45b Mon Sep 17 00:00:00 2001 From: xpflying <17597567+xpflying@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:35:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(openai):=20=E9=9A=90=E7=A7=81/=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E6=A3=80=E6=9F=A5=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20Firefox=20=E6=8C=87=E7=BA=B9=EF=BC=8C=E8=A7=84?= =?UTF-8?q?=E9=81=BF=20chatgpt.com=20Cloudflare=20=E8=B4=A8=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chatgpt.com 的 Cloudflare 会对 req 内置的 ImpersonateChrome(TLS/HTTP2 指纹与 UA 均为 Chrome/120)返回 403 cf-mitigated=challenge,导致 accounts/check、subscriptions、 account_user_setting 三个 backend-api 调用长期失败:订阅到期时间不再更新、隐私开关显示 Fail。 仅改 UA 版本号无效(指纹仍是 120),Firefox 伪装在同一出口下稳定通过,故切换。 同时: - 用 cf-mitigated 响应头识别质询页,隐私设置失败时正确标记为 training_set_cf_blocked - accounts/check / subscriptions 失败日志由 Debug 提升为 Warn 并附 cf_challenge 字段,不再静默 Closes #4825 Co-Authored-By: Claude Fable 5.1 --- .../internal/repository/req_client_pool.go | 11 +++++--- .../repository/req_client_pool_test.go | 9 +++++++ .../openai_privacy_cf_challenge_test.go | 25 +++++++++++++++++++ .../service/openai_privacy_service.go | 19 ++++++++++---- 4 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 backend/internal/service/openai_privacy_cf_challenge_test.go diff --git a/backend/internal/repository/req_client_pool.go b/backend/internal/repository/req_client_pool.go index 95ab27ce32..26f3421ae0 100644 --- a/backend/internal/repository/req_client_pool.go +++ b/backend/internal/repository/req_client_pool.go @@ -17,7 +17,7 @@ import ( type reqClientOptions struct { ProxyURL string // 代理 URL(支持 http/https/socks5) Timeout time.Duration // 请求超时时间 - Impersonate bool // 是否模拟 Chrome 浏览器指纹 + Impersonate bool // 是否模拟浏览器指纹(当前为 Firefox,Chrome 伪装会被 chatgpt.com 的 Cloudflare 质询) ForceHTTP2 bool // 是否强制使用 HTTP/2 } @@ -50,7 +50,12 @@ func getSharedReqClient(opts reqClientOptions) (*req.Client, error) { client = client.EnableForceHTTP2() } if opts.Impersonate { - client = client.ImpersonateChrome() + // chatgpt.com 的 Cloudflare 会对 req 内置的 Chrome 伪装(UA 固定为 Chrome/120, + // 与 sec-ch-ua 等 Client Hints 一起已明显过时)直接返回 403 cf-mitigated=challenge, + // 导致 accounts/check、subscriptions、隐私设置等 backend-api 调用全部失败, + // 订阅到期时间因此长期不更新(见 issue #4825)。Firefox 伪装的 UA/头部组合 + // 在同一出口 IP 下稳定通过,故改用 Firefox 指纹。 + client = client.ImpersonateFirefox() } trimmed, _, err := proxyurl.Parse(opts.ProxyURL) if err != nil { @@ -95,6 +100,6 @@ func CreatePrivacyReqClient(proxyURL string) (*req.Client, error) { return getSharedReqClient(reqClientOptions{ ProxyURL: proxyURL, Timeout: 30 * time.Second, - Impersonate: true, // Enable Chrome TLS fingerprint impersonation + Impersonate: true, // Enable browser TLS fingerprint impersonation (Firefox, see getSharedReqClient) }) } diff --git a/backend/internal/repository/req_client_pool_test.go b/backend/internal/repository/req_client_pool_test.go index 3a27841c5a..12f168f937 100644 --- a/backend/internal/repository/req_client_pool_test.go +++ b/backend/internal/repository/req_client_pool_test.go @@ -140,3 +140,12 @@ func TestInstrumentReqClientRecordsDependency(t *testing.T) { header := collector.HeaderValue(time.Now(), "bypass") require.True(t, strings.Contains(header, "dep_http;dur="), header) } + +func TestGetSharedReqClient_ImpersonateUsesFirefoxFingerprint(t *testing.T) { + sharedReqClients = sync.Map{} + client, err := getSharedReqClient(reqClientOptions{Timeout: time.Second, Impersonate: true}) + require.NoError(t, err) + // chatgpt.com 的 Cloudflare 会质询 req 内置的 Chrome/120 伪装,必须保持 Firefox 指纹。 + require.Contains(t, client.Headers.Get("User-Agent"), "Firefox/") + require.NotContains(t, client.Headers.Get("User-Agent"), "Chrome/") +} diff --git a/backend/internal/service/openai_privacy_cf_challenge_test.go b/backend/internal/service/openai_privacy_cf_challenge_test.go new file mode 100644 index 0000000000..28b96dacc9 --- /dev/null +++ b/backend/internal/service/openai_privacy_cf_challenge_test.go @@ -0,0 +1,25 @@ +package service + +import "testing" + +func TestIsCloudflareChallengeResponse(t *testing.T) { + cases := []struct { + name string + cfMitigated string + body string + want bool + }{ + {"cf-mitigated challenge header", "challenge", "...", true}, + {"header case-insensitive with spaces", " Challenge ", "", true}, + {"body marker only", "", "Just a moment...", true}, + {"plain json error", "", `{"detail":"Unauthorized"}`, false}, + {"empty", "", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isCloudflareChallengeResponse(tc.cfMitigated, tc.body); got != tc.want { + t.Fatalf("isCloudflareChallengeResponse(%q, %q) = %v, want %v", tc.cfMitigated, tc.body, got, tc.want) + } + }) + } +} diff --git a/backend/internal/service/openai_privacy_service.go b/backend/internal/service/openai_privacy_service.go index 192843346b..b2fe4e73b7 100644 --- a/backend/internal/service/openai_privacy_service.go +++ b/backend/internal/service/openai_privacy_service.go @@ -71,7 +71,7 @@ func disableOpenAITraining(ctx context.Context, clientFactory PrivacyClientFacto if resp.StatusCode == 403 || resp.StatusCode == 503 { body := resp.String() - if strings.Contains(body, "cloudflare") || strings.Contains(body, "cf-") || strings.Contains(body, "Just a moment") { + if isCloudflareChallengeResponse(resp.Header.Get("cf-mitigated"), body) { slog.Warn("openai_privacy_cf_blocked", "status", resp.StatusCode) return PrivacyModeCFBlocked } @@ -86,6 +86,15 @@ func disableOpenAITraining(ctx context.Context, clientFactory PrivacyClientFacto return PrivacyModeTrainingOff } +// isCloudflareChallengeResponse 判断 chatgpt.com 返回的是否为 Cloudflare 质询/拦截页。 +// 优先看 cf-mitigated 响应头(质询时为 "challenge"),再回退到正文关键字。 +func isCloudflareChallengeResponse(cfMitigated, body string) bool { + if strings.EqualFold(strings.TrimSpace(cfMitigated), "challenge") { + return true + } + return strings.Contains(body, "cloudflare") || strings.Contains(body, "cf-") || strings.Contains(body, "Just a moment") +} + // ChatGPTAccountInfo 从 chatgpt.com/backend-api/accounts/check 获取的账号信息 type ChatGPTAccountInfo struct { PlanType string @@ -131,12 +140,12 @@ func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFac Get(chatGPTAccountsCheckURL) if err != nil { - slog.Debug("chatgpt_account_check_request_error", "error", err.Error()) + slog.Warn("chatgpt_account_check_request_error", "error", err.Error()) return nil } if !resp.IsSuccessState() { - slog.Debug("chatgpt_account_check_failed", "status", resp.StatusCode, "body", truncate(resp.String(), 200)) + slog.Warn("chatgpt_account_check_failed", "status", resp.StatusCode, "cf_challenge", isCloudflareChallengeResponse(resp.Header.Get("cf-mitigated"), resp.String()), "body", truncate(resp.String(), 200)) return nil } @@ -247,11 +256,11 @@ func fetchChatGPTSubscriptionExpiresAt(ctx context.Context, clientFactory Privac SetQueryParam("account_id", accountID). Get(chatGPTSubscriptionsURL) if err != nil { - slog.Debug("chatgpt_subscription_request_error", "error", err.Error()) + slog.Warn("chatgpt_subscription_request_error", "error", err.Error()) return "" } if !resp.IsSuccessState() { - slog.Debug("chatgpt_subscription_failed", "status", resp.StatusCode, "body", truncate(resp.String(), 200)) + slog.Warn("chatgpt_subscription_failed", "status", resp.StatusCode, "cf_challenge", isCloudflareChallengeResponse(resp.Header.Get("cf-mitigated"), resp.String()), "body", truncate(resp.String(), 200)) return "" }