mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
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:
co-authored by
Claude Opus 5.5
parent
9a62841fd1
commit
d359196adc
@@ -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`)
|
||||
}
|
||||
Reference in New Issue
Block a user