Merge pull request #4306 from wp-a/fix/openai-passthrough-error-sanitization

fix(openai): sanitize passthrough upstream errors
This commit is contained in:
Wesley Liddick
2026-07-15 10:11:41 +08:00
committed by GitHub
2 changed files with 321 additions and 16 deletions
@@ -14,6 +14,7 @@ import (
"io"
"net/http"
"sort"
"strconv"
"strings"
"time"
@@ -453,6 +454,73 @@ func shouldFailoverOpenAIPassthroughResponse(statusCode int) bool {
}
}
func writeOpenAIPassthroughErrorHeaders(dst, src http.Header) {
if dst == nil {
return
}
dst.Set("Content-Type", "application/json; charset=utf-8")
dst.Set("Cache-Control", "no-store")
dst.Del("Retry-After")
if src == nil {
return
}
rawRetryAfter := strings.TrimSpace(src.Get("Retry-After"))
if validOpenAIPassthroughRetryAfter(rawRetryAfter, time.Now()) {
dst.Set("Retry-After", rawRetryAfter)
}
}
func validOpenAIPassthroughRetryAfter(raw string, now time.Time) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return false
}
delaySeconds := true
for i := 0; i < len(raw); i++ {
if raw[i] < '0' || raw[i] > '9' {
delaySeconds = false
break
}
}
if delaySeconds {
seconds, err := strconv.ParseUint(raw, 10, 64)
return err == nil && seconds > 0
}
parsed, err := http.ParseTime(raw)
return err == nil && parsed.After(now)
}
func writeSanitizedOpenAIPassthroughError(c *gin.Context, upstreamStatus int, upstreamHeaders http.Header) {
if c == nil {
return
}
downstreamStatus := upstreamStatus
message := "Upstream request failed"
switch upstreamStatus {
case http.StatusUnauthorized:
downstreamStatus = http.StatusBadGateway
message = "Upstream authentication failed"
case http.StatusForbidden:
downstreamStatus = http.StatusBadGateway
message = "Upstream access denied"
default:
if upstreamStatus >= http.StatusInternalServerError {
message = "Upstream service temporarily unavailable"
}
}
body, _ := json.Marshal(gin.H{
"error": gin.H{
"type": "upstream_error",
"message": message,
},
})
if writeOpenAICompactSSEBridge(c, downstreamStatus, body) {
return
}
writeOpenAIPassthroughErrorHeaders(c.Writer.Header(), upstreamHeaders)
c.Data(downstreamStatus, "application/json; charset=utf-8", body)
}
func (s *OpenAIGatewayService) handleFailoverErrorResponsePassthrough(
ctx context.Context,
resp *http.Response,
@@ -507,8 +575,8 @@ func (s *OpenAIGatewayService) handleErrorResponsePassthrough(
body := s.readUpstreamErrorBody(resp)
body = s.redactAgentIdentitySensitiveBody(ctx, account, body)
// cyber_policy:透传账号本就把原始 body 回给客户端(下方 c.Data),此处仅打标记,
// 供 handler 事后写风控/邮件。cyber 是上游网络安全策略拦截,不冷却账号,
// cyber_policy 仍按原始 body 打内部标记,供 handler 事后写风控/邮件;面向客户端的
// 错误体在下方统一重建。cyber 是上游网络安全策略拦截,不冷却账号,
// 故下方跳过 handleOpenAIAccountUpstreamError(避免自定义 temp-unschedulable 规则误冷却)。
cyberHit, cyberCode, cyberMsg := detectOpenAICyberPolicy(body)
if cyberHit {
@@ -532,8 +600,8 @@ func (s *OpenAIGatewayService) handleErrorResponsePassthrough(
}
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
logOpenAIInstructionsRequiredDebug(ctx, c, account, resp.StatusCode, upstreamMsg, requestBody, body)
// 透传模式保留原始上游错误响应,但运行态账号状态仍需更新,
// 避免粘性路由继续复用刚被限流的账号。cyber 例外:不冷却账号。
// 错误体虽不会原样透传,运行态账号状态仍需更新,避免粘性路由继续复用
// 刚被限流的账号。cyber 例外:不冷却账号。
if !cyberHit {
reqModel, _, _ := extractOpenAIRequestMetaFromBody(requestBody)
_ = s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, reqModel)
@@ -550,18 +618,9 @@ func (s *OpenAIGatewayService) handleErrorResponsePassthrough(
Detail: upstreamDetail,
UpstreamResponseBody: upstreamDetail,
})
writeSanitizedOpenAIPassthroughError(c, resp.StatusCode, resp.Header)
writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/json"
}
c.Data(resp.StatusCode, contentType, body)
if upstreamMsg == "" {
return fmt.Errorf("upstream error: %d", resp.StatusCode)
}
return fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg)
return fmt.Errorf("upstream error: %d (client response sanitized)", resp.StatusCode)
}
func isOpenAIPassthroughAllowedRequestHeader(lowerKey string, allowTimeoutHeaders bool) bool {
@@ -995,7 +995,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_UpstreamErrorIncludesPassthroughF
_, err := svc.Forward(context.Background(), c, account, originalBody)
require.Error(t, err)
require.True(t, c.Writer.Written(), "非 429/529 的 passthrough 错误应继续原样写回客户端")
require.True(t, c.Writer.Written(), "非 429/529 的 passthrough 错误应直接写回客户端")
require.Equal(t, http.StatusBadRequest, rec.Code)
// should append an upstream error event with passthrough=true
@@ -1008,6 +1008,252 @@ func TestOpenAIGatewayService_OAuthPassthrough_UpstreamErrorIncludesPassthroughF
require.Equal(t, "http_error", arr[len(arr)-1].Kind)
}
func TestOpenAIGatewayService_APIKeyPassthrough_RebuildsUpstreamErrors(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
statusCode int
contentType string
responseBody string
retryAfter string
wantStatus int
wantMessage string
wantRetryAfter string
}{
{
name: "upstream forbidden is reported as gateway failure",
statusCode: http.StatusForbidden,
contentType: "text/html; charset=UTF-8",
responseBody: `<!DOCTYPE html><title>secret-upstream.example denied the request</title>`,
retryAfter: "17",
wantStatus: http.StatusBadGateway,
wantMessage: "Upstream access denied",
wantRetryAfter: "17",
},
{
name: "upstream unauthorized is reported as gateway failure",
statusCode: http.StatusUnauthorized,
contentType: "application/json",
responseBody: `{"error":{"message":"invalid secret-upstream.example token","type":"authentication_error","code":"invalid_api_key","param":"api_key"},"rate_limit":{"remaining":0}}`,
wantStatus: http.StatusBadGateway,
wantMessage: "Upstream authentication failed",
},
{
name: "html 5xx",
statusCode: http.StatusBadGateway,
contentType: "text/html; charset=UTF-8",
responseBody: `<!DOCTYPE html><title>secret-upstream.example | 502: Bad gateway</title>`,
wantStatus: http.StatusBadGateway,
wantMessage: "Upstream service temporarily unavailable",
},
{
name: "structured 5xx",
statusCode: http.StatusInternalServerError,
contentType: "application/json",
responseBody: `{"error":{"message":"secret-upstream.example internal failure"}}`,
wantStatus: http.StatusInternalServerError,
wantMessage: "Upstream service temporarily unavailable",
},
{
name: "unstructured 4xx",
statusCode: http.StatusBadRequest,
contentType: "text/plain",
responseBody: `proxy secret-upstream.example rejected the request`,
wantStatus: http.StatusBadRequest,
wantMessage: "Upstream request failed",
},
{
name: "malicious valid json 4xx",
statusCode: http.StatusBadRequest,
contentType: "application/json",
responseBody: `{"error":{"message":"secret-upstream.example invalid parameter","type":"invalid_request_error","code":"upstream_secret_code","param":"private_field","internal_token":"sk-upstream-secret"},"rate_limit":{"remaining":0,"reset":"internal-window"},"debug":{"admin":"root"},"redirect":"https://secret-upstream.example/admin"}`,
retryAfter: "not-a-valid-delay",
wantStatus: http.StatusBadRequest,
wantMessage: "Upstream request failed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.1.0")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: tt.statusCode,
Header: http.Header{
"Content-Type": []string{tt.contentType},
"Location": []string{"https://secret-upstream.example/admin"},
"Retry-After": []string{tt.retryAfter},
"Server": []string{"secret-upstream-proxy"},
"Set-Cookie": []string{"admin_token=secret"},
"WWW-Authenticate": []string{`Bearer realm="secret-upstream.example"`},
"X-Admin-Debug": []string{"internal-route=secret-upstream.example"},
"X-Codex-Primary-Used-Percent": []string{"99"},
"x-request-id": []string{"rid-sensitive-upstream"},
},
Body: io.NopCloser(strings.NewReader(tt.responseBody)),
}}
svc := &OpenAIGatewayService{
cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}},
httpUpstream: upstream,
}
account := &Account{
ID: 124,
Name: "sensitive-upstream",
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Concurrency: 1,
Credentials: map[string]any{
"api_key": "sk-test",
"base_url": "https://secret-upstream.example",
},
Extra: map[string]any{"openai_passthrough": true},
Status: StatusActive,
Schedulable: true,
}
requestBody := []byte(`{"model":"gpt-5.2","stream":false,"input":"hello"}`)
_, err := svc.Forward(context.Background(), c, account, requestBody)
require.Error(t, err)
require.Equal(t, tt.wantStatus, rec.Code)
opsValue, ok := c.Get(OpsUpstreamErrorsKey)
require.True(t, ok)
opsEvents, ok := opsValue.([]*OpsUpstreamErrorEvent)
require.True(t, ok)
require.NotEmpty(t, opsEvents)
require.Equal(t, tt.statusCode, opsEvents[len(opsEvents)-1].UpstreamStatusCode)
require.Contains(t, rec.Header().Get("Content-Type"), "application/json")
require.Equal(t, tt.wantRetryAfter, rec.Header().Get("Retry-After"))
for _, key := range []string{
"Location",
"Server",
"Set-Cookie",
"WWW-Authenticate",
"X-Admin-Debug",
"X-Codex-Primary-Used-Percent",
"X-Request-Id",
} {
require.Empty(t, rec.Header().Values(key), "sensitive upstream header %s must be dropped", key)
}
require.Equal(t, "upstream_error", gjson.Get(rec.Body.String(), "error.type").String())
require.Equal(t, tt.wantMessage, gjson.Get(rec.Body.String(), "error.message").String())
require.False(t, gjson.Get(rec.Body.String(), "error.code").Exists())
require.False(t, gjson.Get(rec.Body.String(), "error.param").Exists())
require.False(t, gjson.Get(rec.Body.String(), "rate_limit").Exists())
require.NotContains(t, rec.Body.String(), "secret-upstream.example")
require.NotContains(t, rec.Body.String(), "sk-upstream-secret")
require.NotContains(t, err.Error(), "secret-upstream.example")
})
}
}
func TestWriteOpenAIPassthroughErrorHeaders_StrictRetryAfter(t *testing.T) {
now := time.Now().UTC()
tests := []struct {
name string
raw string
want bool
}{
{name: "positive delay seconds", raw: "17", want: true},
{name: "fractional delay", raw: "1.5"},
{name: "scientific notation", raw: "1e3"},
{name: "explicit plus sign", raw: "+17"},
{name: "zero", raw: "0"},
{name: "negative delay", raw: "-1"},
{name: "uint64 overflow", raw: "18446744073709551616"},
{name: "future http date", raw: now.Add(time.Hour).Format(http.TimeFormat), want: true},
{name: "past http date", raw: now.Add(-time.Hour).Format(http.TimeFormat)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dst := http.Header{"Retry-After": []string{"stale"}}
writeOpenAIPassthroughErrorHeaders(dst, http.Header{"Retry-After": []string{tt.raw}})
if tt.want {
require.Equal(t, tt.raw, dst.Get("Retry-After"))
} else {
require.Empty(t, dst.Get("Retry-After"))
}
})
}
}
func TestOpenAIGatewayService_APIKeyPassthrough_CompactErrorBeforeKeepaliveIsSingleJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", bytes.NewReader(nil))
MarkOpenAICompactClientStream(c)
stop := StartOpenAICompactSSEKeepalive(c, time.Hour)
defer stop()
svc := &OpenAIGatewayService{
cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}},
httpUpstream: &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusBadRequest,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"secret-upstream.example invalid request"}}`)),
}},
}
account := &Account{
ID: 125, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1,
Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://secret-upstream.example"},
Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true,
}
_, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.2","input":"hello"}`))
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, rec.Code)
require.True(t, gjson.Valid(rec.Body.String()))
require.Equal(t, "upstream_error", gjson.Get(rec.Body.String(), "error.type").String())
require.NotContains(t, rec.Body.String(), "event:")
require.NotContains(t, rec.Body.String(), ": keepalive")
require.NotContains(t, rec.Body.String(), "secret-upstream.example")
}
func TestOpenAIGatewayService_APIKeyPassthrough_CompactErrorAfterKeepaliveIsFailedSSE(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", bytes.NewReader(nil))
MarkOpenAICompactClientStream(c)
stop := StartOpenAICompactSSEKeepalive(c, keepaliveTestInterval)
defer stop()
waitForKeepaliveBeats()
svc := &OpenAIGatewayService{
cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}},
httpUpstream: &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusBadRequest,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"secret-upstream.example invalid request"}}`)),
}},
}
account := &Account{
ID: 126, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1,
Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://secret-upstream.example"},
Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true,
}
_, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.2","input":"hello"}`))
require.Error(t, err)
require.Equal(t, http.StatusOK, rec.Code)
require.Contains(t, rec.Result().Header.Get("Content-Type"), "text/event-stream")
events := parseCompactBridgeSSE(t, stripKeepaliveComments(rec.Body.String()))
require.Len(t, events, 1)
require.Equal(t, "response.failed", events[0][0])
require.Equal(t, "failed", gjson.Get(events[0][1], "response.status").String())
require.Equal(t, "upstream_error", gjson.Get(events[0][1], "response.error.code").String())
require.Equal(t, "Upstream request failed", gjson.Get(events[0][1], "response.error.message").String())
require.NotContains(t, rec.Body.String(), "secret-upstream.example")
}
func TestOpenAIGatewayService_OpenAIPassthrough_RetryableStatusesTriggerFailover(t *testing.T) {
gin.SetMode(gin.TestMode)
originalBody := []byte(`{"model":"gpt-5.2","stream":false,"instructions":"local-test-instructions","input":[{"type":"text","text":"hi"}]}`)