diff --git a/backend/internal/service/antigravity_gateway_gemini.go b/backend/internal/service/antigravity_gateway_gemini.go index 3ce13e6de4..4a8c70dcfb 100644 --- a/backend/internal/service/antigravity_gateway_gemini.go +++ b/backend/internal/service/antigravity_gateway_gemini.go @@ -194,7 +194,6 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co // 处理错误响应 if resp.StatusCode >= 400 { respBody := s.readUpstreamErrorBody(resp) - contentType := resp.Header.Get("Content-Type") // 尽早关闭原始响应体,释放连接;后续逻辑仍可能需要读取 body,因此用内存副本重新包装。 _ = resp.Body.Close() resp.Body = io.NopCloser(bytes.NewReader(respBody)) @@ -301,7 +300,6 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co Header: retryResp.Header.Clone(), Body: io.NopCloser(bytes.NewReader(retryRespBody)), } - contentType = resp.Header.Get("Content-Type") } } else { if switchErr, ok := IsAntigravityAccountSwitchError(retryErr); ok { @@ -393,9 +391,6 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co }) return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: unwrappedForOps} } - if contentType == "" { - contentType = "application/json" - } appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ ProxyID: opsUpstreamProxyID(account), ProxyName: opsUpstreamProxyName(account), @@ -410,7 +405,8 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co }) logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] upstream error status=%d body=%s", resp.StatusCode, truncateForLog(unwrappedForOps, 500)) MarkResponseCommitted(c) - c.Data(resp.StatusCode, contentType, unwrappedForOps) + // 原始上游错误体仅保留在 ops 日志中;返回客户端的错误体需脱敏,避免泄露账号池身份(项目号/服务账号等) + c.Data(resp.StatusCode, "application/json", buildAntigravityClientErrorBody(resp.StatusCode, unwrappedForOps)) return nil, fmt.Errorf("antigravity upstream error: %d", resp.StatusCode) } diff --git a/backend/internal/service/antigravity_upstream_error_sanitize.go b/backend/internal/service/antigravity_upstream_error_sanitize.go new file mode 100644 index 0000000000..b937970d6d --- /dev/null +++ b/backend/internal/service/antigravity_upstream_error_sanitize.go @@ -0,0 +1,97 @@ +package service + +import ( + "encoding/json" + "net/http" + "regexp" + "strings" +) + +var ( + antigravityProjectRefRegex = regexp.MustCompile(`(?i)\bprojects/[a-z0-9][a-z0-9._:-]*`) + antigravityEmailRegex = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`) + antigravityConsumerRegex = regexp.MustCompile(`(?i)\b(consumer|project(?:[ _-]?(?:id|number))?)(\s*[:=]?\s*['"]?)[0-9]{6,}`) +) + +// sanitizeAntigravityErrorText 清除上游错误文本中的 GCP 项目号/项目 ID、服务账号邮箱及敏感查询参数。 +func sanitizeAntigravityErrorText(msg string) string { + if msg == "" { + return msg + } + msg = sanitizeUpstreamErrorMessage(msg) + msg = antigravityProjectRefRegex.ReplaceAllString(msg, "projects/***") + msg = antigravityEmailRegex.ReplaceAllString(msg, "***") + msg = antigravityConsumerRegex.ReplaceAllString(msg, "$1$2***") + return msg +} + +// buildAntigravityClientErrorBody 为客户端构造 Gemini 风格的错误体: +// 仅保留 code/status/message(message 已脱敏),丢弃 details 等可能含账号身份的字段。 +func buildAntigravityClientErrorBody(statusCode int, body []byte) []byte { + code := statusCode + status := "" + message := "" + + var parsed struct { + Error struct { + Code int `json:"code"` + Message string `json:"message"` + Status string `json:"status"` + } `json:"error"` + } + if err := json.Unmarshal(body, &parsed); err == nil { + if parsed.Error.Code != 0 { + code = parsed.Error.Code + } + status = parsed.Error.Status + message = parsed.Error.Message + } + if strings.TrimSpace(message) == "" { + message = strings.TrimSpace(extractUpstreamErrorMessage(body)) + } + if strings.TrimSpace(message) == "" { + message = http.StatusText(statusCode) + if message == "" { + message = "Upstream request failed" + } + } + if status == "" { + status = antigravityGeminiStatusFromHTTP(statusCode) + } + + out, err := json.Marshal(map[string]any{ + "error": map[string]any{ + "code": code, + "message": sanitizeAntigravityErrorText(message), + "status": status, + }, + }) + if err != nil { + return []byte(`{"error":{"code":500,"message":"Upstream request failed","status":"INTERNAL"}}`) + } + return out +} + +func antigravityGeminiStatusFromHTTP(statusCode int) string { + switch statusCode { + case http.StatusBadRequest: + return "INVALID_ARGUMENT" + case http.StatusUnauthorized: + return "UNAUTHENTICATED" + case http.StatusForbidden: + return "PERMISSION_DENIED" + case http.StatusNotFound: + return "NOT_FOUND" + case http.StatusTooManyRequests: + return "RESOURCE_EXHAUSTED" + case http.StatusServiceUnavailable: + return "UNAVAILABLE" + case http.StatusGatewayTimeout: + return "DEADLINE_EXCEEDED" + default: + if statusCode >= 500 { + return "INTERNAL" + } + return "UNKNOWN" + } +} diff --git a/backend/internal/service/antigravity_upstream_error_sanitize_test.go b/backend/internal/service/antigravity_upstream_error_sanitize_test.go new file mode 100644 index 0000000000..3f4abf04a2 --- /dev/null +++ b/backend/internal/service/antigravity_upstream_error_sanitize_test.go @@ -0,0 +1,50 @@ +//go:build unit + +package service + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestBuildAntigravityClientErrorBody_ScrubsPoolIdentity(t *testing.T) { + gin.SetMode(gin.TestMode) + upstream := []byte(`{"error":{"code":403,"message":"Permission denied on resource project projects/123456789 for consumer: projects/123456789; caller pool-sa@my-gcp-proj.iam.gserviceaccount.com","status":"PERMISSION_DENIED","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","metadata":{"consumer":"projects/123456789","service":"cloudcode-pa.googleapis.com"}}]}}`) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Data(http.StatusForbidden, "application/json", buildAntigravityClientErrorBody(http.StatusForbidden, upstream)) + + require.Equal(t, http.StatusForbidden, rec.Code) + out := rec.Body.String() + require.NotContains(t, out, "123456789") + require.NotContains(t, out, "pool-sa@") + require.NotContains(t, out, "gserviceaccount.com") + require.NotContains(t, out, "details") + + var parsed struct { + Error struct { + Code int `json:"code"` + Message string `json:"message"` + Status string `json:"status"` + } `json:"error"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &parsed)) + require.Equal(t, 403, parsed.Error.Code) + require.Equal(t, "PERMISSION_DENIED", parsed.Error.Status) + require.True(t, strings.Contains(parsed.Error.Message, "Permission denied")) +} + +func TestBuildAntigravityClientErrorBody_NonJSONBody(t *testing.T) { + out := string(buildAntigravityClientErrorBody(http.StatusTooManyRequests, []byte("quota exceeded for consumer 987654321 sa@x.iam.gserviceaccount.com"))) + require.NotContains(t, out, "987654321") + require.NotContains(t, out, "gserviceaccount") + require.Contains(t, out, `"status":"RESOURCE_EXHAUSTED"`) + require.Contains(t, out, `"code":429`) +}