From 6cf645f87cbe3b616fd4a977e472ee4253d8bc85 Mon Sep 17 00:00:00 2001 From: r266-tech Date: Fri, 4 Sep 2026 12:45:06 +0800 Subject: [PATCH] fix(openai): scope OpenCode session forwarding --- .../service/openai_gateway_cc_pipeline.go | 1 + .../service/openai_gateway_forward.go | 1 + .../service/openai_gateway_passthrough.go | 1 + .../service/openai_opencode_session.go | 37 +++ .../service/openai_opencode_session_test.go | 223 ++++++++++++++++++ 5 files changed, 263 insertions(+) create mode 100644 backend/internal/service/openai_opencode_session.go create mode 100644 backend/internal/service/openai_opencode_session_test.go diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index 325081d9da..6458509389 100644 --- a/backend/internal/service/openai_gateway_cc_pipeline.go +++ b/backend/internal/service/openai_gateway_cc_pipeline.go @@ -223,6 +223,7 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest( // 账号级请求头覆写:放在所有内置默认头(含 Grok CLI 身份头)之后应用, // 使配置值获得除共享传输层强制头之外的最高优先级。 account.ApplyHeaderOverrides(upstreamReq.Header) + applyOpenCodeSessionHeader(c, account, targetURL, upstreamReq.Header) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 119d446ccf..6b2a3e607f 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -1440,6 +1440,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) account.ApplyHeaderOverrides(req.Header) + applyOpenCodeSessionHeader(c, account, targetURL, req.Header) // x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后, // 保证不被覆盖丢失)。 applyOpenAICodexBetaFeatures(c, account, req.Header) diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index d2b49a2f9a..914e45d3a5 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -720,6 +720,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) account.ApplyHeaderOverrides(req.Header) + applyOpenCodeSessionHeader(c, account, targetURL, req.Header) // x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后, // 保证不被覆盖丢失)。 applyOpenAICodexBetaFeatures(c, account, req.Header) diff --git a/backend/internal/service/openai_opencode_session.go b/backend/internal/service/openai_opencode_session.go new file mode 100644 index 0000000000..fbb0b97eb4 --- /dev/null +++ b/backend/internal/service/openai_opencode_session.go @@ -0,0 +1,37 @@ +package service + +import ( + "net/http" + "net/url" + "strings" + + "github.com/gin-gonic/gin" +) + +const openCodeSessionHeader = "X-OpenCode-Session" + +// applyOpenCodeSessionHeader forwards the caller-owned conversation identifier +// only to OpenCode's official API origin. The caller applies this after account +// header overrides so a per-conversation value cannot be replaced by a fixed +// account-wide override. +func applyOpenCodeSessionHeader(c *gin.Context, account *Account, targetURL string, headers http.Header) { + if c == nil || c.Request == nil || account == nil || account.Type != AccountTypeAPIKey || headers == nil { + return + } + + parsed, err := url.Parse(targetURL) + if err != nil || !strings.EqualFold(parsed.Scheme, "https") || !strings.EqualFold(parsed.Hostname(), "opencode.ai") { + return + } + + sessionID := strings.TrimSpace(c.GetHeader(openCodeSessionHeader)) + if sessionID == "" { + return + } + for key := range headers { + if strings.EqualFold(key, openCodeSessionHeader) { + delete(headers, key) + } + } + headers.Set(openCodeSessionHeader, sessionID) +} diff --git a/backend/internal/service/openai_opencode_session_test.go b/backend/internal/service/openai_opencode_session_test.go new file mode 100644 index 0000000000..fe34a95958 --- /dev/null +++ b/backend/internal/service/openai_opencode_session_test.go @@ -0,0 +1,223 @@ +package service + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func newOpenCodeSessionTestContext(t *testing.T, value string) *gin.Context { + t.Helper() + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + if value != "" { + c.Request.Header.Set(openCodeSessionHeader, value) + } + return c +} + +func openCodeSessionTestService() *OpenAIGatewayService { + return &OpenAIGatewayService{cfg: &config.Config{ + Security: config.SecurityConfig{ + URLAllowlist: config.URLAllowlistConfig{Enabled: false}, + }, + }} +} + +func openCodeSessionTestAccount(baseURL string) *Account { + return &Account{ + ID: 1, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": baseURL, + credKeyHeaderOverrideEnabled: true, + credKeyHeaderOverrides: map[string]any{"x-opencode-session": "fixed-account-value"}, + }, + } +} + +func requireSingleOpenCodeSessionHeader(t *testing.T, headers http.Header, want string) { + t.Helper() + count := 0 + for key, values := range headers { + if strings.EqualFold(key, openCodeSessionHeader) { + count += len(values) + require.Equal(t, []string{want}, values) + } + } + require.Equal(t, 1, count) +} + +func TestApplyOpenCodeSessionHeaderTrustBoundary(t *testing.T) { + tests := []struct { + name string + account *Account + targetURL string + incoming string + want string + }{ + { + name: "official origin", + account: openCodeSessionTestAccount("https://opencode.ai/zen/v1"), + targetURL: "https://opencode.ai/zen/v1/chat/completions", + incoming: " conversation-123 ", + want: "conversation-123", + }, + { + name: "lookalike origin", + account: openCodeSessionTestAccount("https://opencode.ai.evil.example/v1"), + targetURL: "https://opencode.ai.evil.example/v1/responses", + incoming: "conversation-123", + }, + { + name: "subdomain is not implicitly trusted", + account: openCodeSessionTestAccount("https://api.opencode.ai/v1"), + targetURL: "https://api.opencode.ai/v1/responses", + incoming: "conversation-123", + }, + { + name: "insecure official origin", + account: openCodeSessionTestAccount("http://opencode.ai/zen/v1"), + targetURL: "http://opencode.ai/zen/v1/responses", + incoming: "conversation-123", + }, + { + name: "missing caller value", + account: openCodeSessionTestAccount("https://opencode.ai/zen/v1"), + targetURL: "https://opencode.ai/zen/v1/responses", + }, + { + name: "oauth account", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + targetURL: "https://opencode.ai/zen/v1/responses", + incoming: "conversation-123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + headers := make(http.Header) + applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, tt.incoming), tt.account, tt.targetURL, headers) + require.Equal(t, tt.want, headers.Get(openCodeSessionHeader)) + }) + } +} + +func TestOpenCodeSessionForwardedByResponsesBuildersAfterAccountOverride(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := openCodeSessionTestService() + account := openCodeSessionTestAccount("https://opencode.ai/zen/v1") + body := []byte(`{"model":"gpt-5","input":"hello"}`) + + tests := []struct { + name string + build func(*gin.Context) (*http.Request, error) + }{ + { + name: "normal responses", + build: func(c *gin.Context) (*http.Request, error) { + return svc.buildUpstreamRequest(context.Background(), c, account, body, "token", false, "", false) + }, + }, + { + name: "passthrough responses", + build: func(c *gin.Context) (*http.Request, error) { + return svc.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, account, body, "token") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := newOpenCodeSessionTestContext(t, "conversation-456") + req, err := tt.build(c) + require.NoError(t, err) + requireSingleOpenCodeSessionHeader(t, req.Header, "conversation-456") + }) + } +} + +func TestOpenCodeSessionMissingCallerValueKeepsExistingOverrideBehavior(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := openCodeSessionTestService() + account := openCodeSessionTestAccount("https://opencode.ai/zen/v1") + c := newOpenCodeSessionTestContext(t, "") + + req, err := svc.buildUpstreamRequest( + context.Background(), c, account, + []byte(`{"model":"gpt-5","input":"hello"}`), "token", false, "", false, + ) + require.NoError(t, err) + require.Equal(t, "fixed-account-value", getHeaderRaw(req.Header, "x-opencode-session")) +} + +type openCodeSessionHTTPUpstream struct { + request *http.Request +} + +func (u *openCodeSessionHTTPUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + u.request = req + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{}`)), + }, nil +} + +func (u *openCodeSessionHTTPUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) { + return u.Do(req, proxyURL, accountID, concurrency) +} + +func TestOpenCodeSessionForwardedByRawChatCompletionsAfterAccountOverride(t *testing.T) { + gin.SetMode(gin.TestMode) + upstream := &openCodeSessionHTTPUpstream{} + svc := openCodeSessionTestService() + svc.httpUpstream = upstream + account := openCodeSessionTestAccount("https://opencode.ai/zen/v1") + c := newOpenCodeSessionTestContext(t, "conversation-789") + + resp, err := svc.sendCCUpstreamRequest( + context.Background(), c, account, + "https://opencode.ai/zen/v1/chat/completions", []byte(`{"model":"gpt-5"}`), + false, "token", "", "", + ) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.NotNil(t, upstream.request) + requireSingleOpenCodeSessionHeader(t, upstream.request.Header, "conversation-789") +} + +func TestOpenCodeSessionIsNotForwardedToOtherUpstreams(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := openCodeSessionTestService() + body := []byte(`{"model":"gpt-5","input":"hello"}`) + + for _, baseURL := range []string{ + "https://api.openai.com/v1", + "https://opencode.ai.evil.example/v1", + "https://api.opencode.ai/v1", + } { + t.Run(baseURL, func(t *testing.T) { + account := &Account{ + ID: 1, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": baseURL}, + } + c := newOpenCodeSessionTestContext(t, "private-conversation") + req, err := svc.buildUpstreamRequest(context.Background(), c, account, body, "token", false, "", false) + require.NoError(t, err) + require.Empty(t, req.Header.Get(openCodeSessionHeader)) + }) + } +}