Merge pull request #7674 from william-drakemond/fix/antigravity-sanitize-upstream-error

fix(antigravity): 返回客户端前脱敏上游错误体,避免泄露账号池身份
This commit is contained in:
Wesley Liddick
2026-10-02 14:32:12 +08:00
committed by GitHub
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`)
}