From 4e5632c3e32f9a8a5150c44e8a7f46efe7fc2688 Mon Sep 17 00:00:00 2001 From: smyyyyy <1298311250@qq.com> Date: Thu, 10 Sep 2026 03:18:31 +0800 Subject: [PATCH] fix: validate Codex User-Agent before identity parsing --- backend/internal/pkg/openai/request.go | 15 ++++++++ .../pkg/openai/request_ua_validation_test.go | 37 +++++++++++++++++++ .../internal/service/openai_codex_identity.go | 9 +++-- .../service/openai_ua_validation_test.go | 29 +++++++++++++++ 4 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 backend/internal/pkg/openai/request_ua_validation_test.go create mode 100644 backend/internal/service/openai_ua_validation_test.go diff --git a/backend/internal/pkg/openai/request.go b/backend/internal/pkg/openai/request.go index 172021b8ea..8ddf8d188d 100644 --- a/backend/internal/pkg/openai/request.go +++ b/backend/internal/pkg/openai/request.go @@ -3,6 +3,8 @@ package openai import ( "regexp" "strings" + + "golang.org/x/net/http/httpguts" ) // CodexCLIUserAgentPrefixes matches Codex CLI User-Agent patterns @@ -198,6 +200,10 @@ func matchCodexClientHeaderStrictPrefixes(value string, prefixes []string) bool // UA 首段后配对,保留真实版本/OS/终端指纹; // 3. 均不命中 → ok=false,调用方应整体回退为默认官方身份。 func PairCodexClientIdentity(userAgent string) (originator string, pairedUA string, ok bool) { + // Validate before trimming so control bytes cannot become a valid identity. + if !validCodexUserAgentValue(userAgent) { + return "", "", false + } ua := strings.TrimSpace(userAgent) slash := strings.IndexByte(ua, '/') if slash <= 0 { @@ -217,6 +223,15 @@ func PairCodexClientIdentity(userAgent string) (originator string, pairedUA stri return "", "", false } +func validCodexUserAgentValue(value string) bool { + if !httpguts.ValidHeaderFieldValue(value) { + return false + } + // httpguts follows the legacy field-value grammar and permits obs-fold. + // User-Agent is not an obs-folded header; reject CR/LF before forwarding. + return !strings.ContainsAny(value, "\r\n") +} + // codexOriginatorMaxLen 官方 clientInfo.name 均为短 ASCII 标识,远低于此上限。 const codexOriginatorMaxLen = 64 diff --git a/backend/internal/pkg/openai/request_ua_validation_test.go b/backend/internal/pkg/openai/request_ua_validation_test.go new file mode 100644 index 0000000000..a74c86f90a --- /dev/null +++ b/backend/internal/pkg/openai/request_ua_validation_test.go @@ -0,0 +1,37 @@ +package openai + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPairCodexClientIdentityRejectsInvalidHeaderBytes(t *testing.T) { + for name, ua := range map[string]string{ + "crlf_suffix": "codex-tui/0.146.0 (Linux)\r\nX-Injected: value", + "nul_suffix": "codex-tui/0.146.0 (Linux\x00)", + "del_suffix": "codex-tui/0.146.0 terminal\x7f", + "newline_prefix": "\ncodex-tui/0.146.0", + "newline_suffix": "codex-tui/0.146.0\n", + "control_in_trailer": "custom/0.146.0 bad\x01 (codex-tui; 0.146.0)", + } { + t.Run(name, func(t *testing.T) { + originator, paired, ok := PairCodexClientIdentity(ua) + require.False(t, ok) + require.Empty(t, originator) + require.Empty(t, paired) + }) + } +} + +func TestPairCodexClientIdentityPreservesValidSuffix(t *testing.T) { + for _, ua := range []string{ + "codex-tui/0.146.0 (Mac OS X 14.0; arm64) iTerm", + "codex-tui/0.146.0 (Windows NT 10.0; x86_64) terminal", + "codex-tui/0.146.0 (Linux)\tterminal", + } { + _, paired, ok := PairCodexClientIdentity(ua) + require.True(t, ok) + require.Equal(t, ua, paired) + } +} diff --git a/backend/internal/service/openai_codex_identity.go b/backend/internal/service/openai_codex_identity.go index 2cb8a45c40..09d1717c9a 100644 --- a/backend/internal/service/openai_codex_identity.go +++ b/backend/internal/service/openai_codex_identity.go @@ -114,7 +114,7 @@ func codexCanonicalUserAgent() string { resolver := codexCanonicalUAResolver codexCanonicalUAMu.RUnlock() if resolver != nil { - if ua := strings.TrimSpace(resolver()); ua != "" { + if ua := resolver(); strings.TrimSpace(ua) != "" { return ua } } @@ -139,8 +139,11 @@ type codexOutboundIdentity struct { // 需要固定版本请填「Codex 客户端版本号」并关闭自动同步。 func resolveCodexOutboundIdentity(candidateUA string) codexOutboundIdentity { canonical := codexCanonicalUserAgent() - ua := strings.TrimSpace(candidateUA) - if ua == "" { + if _, _, ok := openai.PairCodexClientIdentity(canonical); !ok { + canonical = codexCLIUserAgent + } + ua := candidateUA + if strings.TrimSpace(ua) == "" { ua = canonical } originator, pairedUA, ok := openai.PairCodexClientIdentity(ua) diff --git a/backend/internal/service/openai_ua_validation_test.go b/backend/internal/service/openai_ua_validation_test.go new file mode 100644 index 0000000000..08872f4059 --- /dev/null +++ b/backend/internal/service/openai_ua_validation_test.go @@ -0,0 +1,29 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCanonicalCodexIdentityValidatesBeforeTrimming(t *testing.T) { + codexCanonicalUAMu.RLock() + previous := codexCanonicalUAResolver + codexCanonicalUAMu.RUnlock() + t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(previous) }) + + for _, raw := range []string{ + "\ncodex-tui/0.200.1 (Windows)", + "codex-tui/0.200.1 (Windows)\n", + "codex-tui/0.200.1 bad\x00", + } { + SetCodexCanonicalUserAgentResolver(func() string { return raw }) + identity := resolveCodexOutboundIdentity("") + require.Equal(t, codexCLIUserAgent, identity.userAgent) + require.Equal(t, codexCLIVersion, identity.version) + } + + SetCodexCanonicalUserAgentResolver(nil) + identity := resolveCodexOutboundIdentity("codex_cli_rs/0.145.2 (Windows)\n") + require.Equal(t, codexCLIUserAgent, identity.userAgent) +}