From d359196adcac5f051d137cc2a8e601c32f1d2f1f Mon Sep 17 00:00:00 2001 From: William Drake Date: Mon, 28 Sep 2026 12:23:13 -0400 Subject: [PATCH] fix(antigravity): sanitize upstream error body before returning to client The antigravity Gemini forward path returned the raw upstream Google error body to end users, which can contain consumer project numbers, GCP project IDs and service account emails. Return a Gemini-style error body with only code/status and a scrubbed message; raw body stays in ops logging. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../service/antigravity_gateway_gemini.go | 8 +- .../antigravity_upstream_error_sanitize.go | 97 +++++++++++++++++++ ...ntigravity_upstream_error_sanitize_test.go | 50 ++++++++++ 3 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 backend/internal/service/antigravity_upstream_error_sanitize.go create mode 100644 backend/internal/service/antigravity_upstream_error_sanitize_test.go 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`) +}