fix: validate Codex User-Agent before identity parsing

This commit is contained in:
smyyyyy
2026-09-10 03:18:31 +08:00
parent 98d86915be
commit 4e5632c3e3
4 changed files with 87 additions and 3 deletions
+15
View File
@@ -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
@@ -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)
}
}
@@ -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)
@@ -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)
}