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) <noreply@anthropic.com>
This commit is contained in:
William Drake
2026-09-28 12:23:13 -04:00
co-authored by Claude Opus 5.5
parent 9a62841fd1
commit d359196adc
3 changed files with 149 additions and 6 deletions
@@ -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)
}
@@ -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"
}
}
@@ -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`)
}