Skip to content

Commit 7302cf6

Browse files
committed
优化伪装/修复Bug
1 parent 9dfed48 commit 7302cf6

31 files changed

Lines changed: 1041 additions & 127 deletions
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
# Sub2API 伪装性审计报告
2+
3+
> 目标:从"上游官方 API(Anthropic/OpenAI/Google)能否识别此请求来自中转反代"的角度审查代码
4+
5+
---
6+
7+
## 风险分级总览
8+
9+
| 等级 | 数量 | 含义 |
10+
|------|------|------|
11+
| 🔴 高 | 2 | 上游可直接通过此类特征判定请求来自中转 |
12+
| 🟡 中 | 6 | 结合其他信号可推断,但单独不构成铁证(含原 H2 IP 传递,经核实降级) |
13+
| 🟢 低 | 4 | 理论可检测,实际几乎不会被用于判定 |
14+
| ✅ 已做好 | 6 | 项目已实施的伪装措施(正面评价) |
15+
16+
---
17+
18+
## 一、🔴 高风险(应优先修改)
19+
20+
### H1. HTTP 400 错误体原样透传
21+
22+
**文件**: `backend/internal/service/gateway_service.go:7417-7419`
23+
24+
```go
25+
case 400:
26+
c.Data(http.StatusBadRequest, "application/json", body) // ← 上游原始错误体直接返回
27+
```
28+
29+
**风险**: 上游 400 响应体包含平台特有格式和措辞(如 Anthropic 的 `{"type":"error","error":{"type":"invalid_request_error",...}}`),直接透传等于把上游 API 的"指纹"原封不动告诉客户端。虽然这不直接暴露给上游,但如果官方做 entrapment(给特定账号投递带水印的 400 错误),可以追溯中转链路。
30+
31+
**建议**: 对 400 错误体做结构化提取和重新封装,使用 Anthropic 标准格式重新构建错误响应,而非原样透传。
32+
33+
---
34+
35+
### H2. 客户端 IP 通过 X-Forwarded-For / X-Real-IP 传递给上游
36+
37+
**文件**: `backend/internal/pkg/ip/ip.go:14-29`
38+
39+
```go
40+
// 2. Nginx X-Real-IP
41+
if ip := c.GetHeader("X-Real-IP"); ip != "" { ... }
42+
// 3. X-Forwarded-For
43+
if xff := c.GetHeader("X-Forwarded-For"); xff != "" { ... }
44+
```
45+
46+
**Anthropic 路径**: `gateway_service.go:6375-6384` — 请求头白名单透传中 **不含** X-Forwarded-For / X-Real-IP(✅ 安全)
47+
48+
**OpenAI 路径**: `openai_gateway_service.go:110-111``codexCLIOnlyDebugHeaderWhitelist` 包含 X-Forwarded-For 和 X-Real-IP,但这**仅用于诊断日志**,不参与上游透传(✅ 安全)
49+
50+
**实际风险点**: 项目本身**没有**主动将客户端 IP 设置到转发给上游的请求中。但如果部署在 Nginx/Caddy 反代后面,且 Nginx 配置了 `proxy_set_header X-Forwarded-For`,Go 的 `net/http` 会将这些头从入站请求复制到出站请求吗?**不会**,Go HTTP 客户端不会自动转发入站头。项目代码中 `buildUpstreamRequest` 是构建全新 `http.NewRequest`,仅复制白名单头。
51+
52+
**结论**: 此项实际**不构成直接风险**(项目白名单已正确排除 IP 头),但需要确保部署时上游反代不会添加此类头。降级为 🟡 中。
53+
54+
---
55+
56+
### H3. OpenAI SSE 错误事件使用非原生类型 `"upstream_error"`
57+
58+
**文件**: `backend/internal/service/openai_gateway_service.go:4636`
59+
60+
```go
61+
payload := `{"type":"error","sequence_number":0,"error":{"type":"upstream_error","message":...`
62+
```
63+
64+
**风险**: 原生 OpenAI Responses API 的错误类型有 `rate_limit_error``server_error``invalid_request_error` 等,**从未使用** `upstream_error`。如果客户端将此错误类型转发给 OpenAI(如 telemetry),或者官方检查客户端日志中的错误类型模式,`upstream_error` 是中转服务的明确标志。
65+
66+
**建议**: 将 `"upstream_error"` 替换为原生 OpenAI 错误类型(如 `"server_error"``"rate_limit_error"`),保持与上游一致。
67+
68+
---
69+
70+
## 二、🟡 中风险(建议修改)
71+
72+
### M1. `x-codex-*` 响应头强制放行
73+
74+
**文件**: `backend/internal/service/openai_gateway_service.go:4079-4112`
75+
76+
```go
77+
for _, rawKey := range []string{
78+
"x-codex-primary-used-percent",
79+
"x-codex-secondary-used-percent",
80+
...
81+
} {
82+
// 强制透传这些头
83+
}
84+
```
85+
86+
**风险**: 这些头是 OpenAI Codex 专有的速率限制头。虽然白名单过滤了其他所有头,但这里额外放行了 7 个 x-codex-* 头。对于客户端使用来说这是功能必需(显示用量),但**如果官方检测代理返回的响应头集合**,x-codex-* 的大量存在可能被视为间接证据。不过这些头原生 Codex 客户端也会收到,**风险有限**
87+
88+
---
89+
90+
### M2. 前端 HTML title 直接暴露项目名
91+
92+
**文件**: `frontend/index.html:7`
93+
94+
```html
95+
<title>Sub2API - AI API Gateway</title>
96+
```
97+
98+
**风险**: 虽然这个页面不会发给上游 API,但**你的中转站对客户端可见**。如果用户把中转站 URL 分享到公开平台,搜索引擎或爬虫抓取到 `<title>Sub2API</title>` 就直接暴露了使用的程序。
99+
100+
**建议**: 改为自定义名称。
101+
102+
---
103+
104+
### M3. SSE keepalive ping 行为
105+
106+
**文件**: `backend/internal/service/gateway_service.go:7681-7694`
107+
108+
```go
109+
keepaliveInterval := ...
110+
// 下游 keepalive:防止代理/Cloudflare Tunnel 因连接空闲而断开
111+
```
112+
113+
**风险**: 真实的 Anthropic/OpenAI API **不会**在等待首个 token 时发送 `data: {"type": "ping"}` 类型的 keepalive。如果客户端将这些 ping 的时序/格式记录下来并上报,可能暴露中转行为。但**默认未启用**(需配置 `stream_keepalive_interval`),风险可控。
114+
115+
---
116+
117+
### M4. ChatCompletions/Responses 路径的上游错误消息
118+
119+
**文件**:
120+
- `gateway_forward_as_chat_completions.go:177`
121+
- `gateway_forward_as_responses.go:175`
122+
123+
```go
124+
writeGatewayCCError(c, mapUpstreamStatusCode(resp.StatusCode), "server_error", upstreamMsg)
125+
```
126+
127+
**风险**: `upstreamMsg` 经过 `sanitizeUpstreamErrorMessage` 处理,但该函数仅做 URL 参数脱敏(替换 `key=xxx``key=***`),**不会**清理上游特有措辞。例如 Anthropic 的 `"Your API key is invalid"` 或 OpenAI 的 `"You exceeded your current quota"` 会原样传递。
128+
129+
---
130+
131+
### M5. 项目元数据指纹(go.mod 模块路径、二进制名等)
132+
133+
| 来源 | 暴露内容 |
134+
|------|---------|
135+
| `backend/go.mod` | `module github.com/Wei-Shaw/sub2api` |
136+
| Dockerfile | `LABEL ... "Sub2API - AI API Gateway Platform"` |
137+
| Dockerfile | `LABEL org.opencontainers.image.source="https://github.com/Wei-Shaw/sub2api"` |
138+
| Dockerfile | 二进制路径 `/app/sub2api` |
139+
| 前端 | `siteName = 'Sub2API'`、i18n 多处 |
140+
| 配置 | `LOG_SERVICE_NAME=sub2api`、Redis key prefix `sub2api:` |
141+
142+
**风险**: 这些信息**不直接被上游 API 看到**(上游只看到 HTTP 请求),但对于服务器安全审计、反指纹有影响。如果你不想让任何人知道你用的是 sub2api,这些都需要改。
143+
144+
---
145+
146+
## 三、🟢 低风险(了解即可)
147+
148+
### L1. Mock 拦截响应使用固定消息 ID
149+
150+
**文件**: `backend/internal/handler/gateway_handler.go:1920-1921`
151+
152+
```go
153+
msgID = "msg_mock_warmup" // 固定值
154+
msgID = "msg_mock_suggestion" // 固定值
155+
```
156+
157+
**说明**: 这仅用于拦截预热/建议请求(不发给上游),不会被上游看到。但客户端如果分析响应 ID 格式,可以发现 `msg_mock_*` 不是真实的 Anthropic 格式。已有 `generateRealisticMsgID()` 用于 haiku 探测响应,建议统一。
158+
159+
### L2. SSE ping 格式差异
160+
161+
**文件**: `backend/internal/service/gateway_helper.go:106-115`
162+
163+
不同平台使用不同的 ping 格式:
164+
- Anthropic: `data: {"type": "ping"}\n\n`
165+
- OpenAI: SSE 注释 `:\n\n`
166+
167+
**说明**: 这与各平台原生行为一致,**不应修改**
168+
169+
### L3. x-request-id 透传
170+
171+
**说明**: 透传上游的 x-request-id 给客户端。上游的 request-id 格式是标准的,不构成明显指纹。风险极低。
172+
173+
### L4. X-Accel-Buffering: no 头
174+
175+
**说明**: 所有 SSE 路径设置 `X-Accel-Buffering: no`。这是 Nginx 专有头,暴露后端使用 Nginx,但不暴露"中转"身份。功能必需。
176+
177+
---
178+
179+
## 四、✅ 已做好的伪装措施(正面评价)
180+
181+
| 措施 | 文件/位置 | 说明 |
182+
|------|-----------|------|
183+
| **响应头白名单过滤** | `responseheaders.go` | 默认启用,仅允许 16 个头透传,阻断 Server/Via/CF-*/X-Powered-By 等 |
184+
| **错误码通用化** | `gateway_service.go:7428-7451` | 401/403/429/529/5xx 全部映射为通用消息 |
185+
| **Panic 不泄露堆栈** | `recovery.go` | 只返回 `"internal error"` |
186+
| **Claude Code 指纹注入** | `gateway_service.go:6301-6407` | OAuth 账号自动注入 x-stainless-*、User-Agent、metadata.user_id 等完整 CC 指纹 |
187+
| **TLS 指纹伪装** | `req_client_pool.go` + `tlsfingerprint`| 使用 `ImpersonateChrome()` 伪装 TLS 握手 |
188+
| **模型名替换** | 流式/非流式均支持 | 请求和响应中的模型名自动映射 |
189+
190+
---
191+
192+
## 五、推荐修改优先级
193+
194+
**投入产出比**排序(最值得先做的在前):
195+
196+
| 序号 | 修改项 | 风险等级 | 改动范围 | 说明 |
197+
|------|--------|---------|---------|------|
198+
| 1 | OpenAI SSE error 类型伪装 | 🔴 高 | 1 个文件 | `"upstream_error"` → 原生类型,小改动大收益 |
199+
| 2 | 400 错误体结构化重封装 | 🔴 高 | 1 个文件 | 阻止上游水印追溯 |
200+
| 3 | 前端 title/i18n 消品牌化 | 🟡 中 | 少量前端文件 | 防止站名暴露 |
201+
| 4 | 上游错误消息深度脱敏 | 🟡 中 | 1-2 个文件 | 清理上游措辞特征 |
202+
| 5 | keepalive ping 格式优化 | 🟡 中 | 1 个文件 | 让 ping 行为更贴近原生 |
203+
| 6 | x-codex-* 头评估 | 🟡 中 | 1 个文件 | 可选:是否保留功能性透传 |
204+
| 7 | 二进制/模块/容器消品牌化 | 🟡 中 | 多文件 | 全方位去除 sub2api 痕迹 |
205+
206+
****: 第 1-2 项是"上游反检测"的核心修改;第 3-7 项更多是"运营消品牌"层面。如果你只关心上游不被检测到,重点做 1-2 即可。如果你想彻底去除 sub2api 痕迹(让用户也不知道用的是 sub2api),则 3-7 也需要做。

Dockerfile

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,7 @@ FROM ${POSTGRES_IMAGE} AS pg-client
8484
FROM ${ALPINE_IMAGE}
8585

8686
# Labels
87-
LABEL maintainer="Wei-Shaw <github.com/Wei-Shaw>"
88-
LABEL description="Sub2API - AI API Gateway Platform"
89-
LABEL org.opencontainers.image.source="https://github.com/Wei-Shaw/sub2api"
87+
LABEL description="AI API Gateway"
9088

9189
# Install runtime dependencies
9290
RUN apk add --no-cache \

Dockerfile.goreleaser

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@ FROM ${POSTGRES_IMAGE} AS pg-client
1212

1313
FROM ${ALPINE_IMAGE}
1414

15-
LABEL maintainer="Wei-Shaw <github.com/Wei-Shaw>"
16-
LABEL description="Sub2API - AI API Gateway Platform"
17-
LABEL org.opencontainers.image.source="https://github.com/Wei-Shaw/sub2api"
15+
LABEL description="AI API Gateway"
1816

1917
# Install runtime dependencies
2018
RUN apk add --no-cache \

backend/cmd/server/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func main() {
6262
flag.Parse()
6363

6464
if *showVersion {
65-
log.Printf("Sub2API %s (commit: %s, built: %s)\n", Version, Commit, Date)
65+
log.Printf("AI-Gateway %s (commit: %s, built: %s)\n", Version, Commit, Date)
6666
return
6767
}
6868

backend/internal/handler/gateway_handler.go

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,14 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
195195
return
196196
}
197197

198+
// 计费预检:转发前确认该模型存在可用定价(渠道/动态/fallback 任一),
199+
// 否则直接拒绝,避免请求成功转发后才发现无定价而静默按 0 计费(白嫖)。
200+
if !h.gatewayService.HasPricingForModel(c.Request.Context(), reqModel, apiKey) {
201+
reqLog.Warn("gateway.model_pricing_not_configured", zap.String("model", reqModel))
202+
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "model pricing is not configured: "+reqModel)
203+
return
204+
}
205+
198206
if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, reqModel, body); decision != nil && decision.Blocked {
199207
h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
200208
return
@@ -1565,22 +1573,23 @@ func (h *GatewayHandler) handleFailoverExhaustedSimple(c *gin.Context, statusCod
15651573
func (h *GatewayHandler) mapUpstreamError(statusCode int) (int, string, string) {
15661574
switch statusCode {
15671575
case 401:
1568-
return http.StatusBadGateway, "upstream_error", "Upstream authentication failed, please contact administrator"
1576+
return http.StatusBadGateway, "api_error", "Upstream authentication failed, please contact administrator"
15691577
case 403:
1570-
return http.StatusBadGateway, "upstream_error", "Upstream access forbidden, please contact administrator"
1578+
return http.StatusBadGateway, "api_error", "Upstream access forbidden, please contact administrator"
15711579
case 429:
15721580
return http.StatusTooManyRequests, "rate_limit_error", "Upstream rate limit exceeded, please retry later"
15731581
case 529:
15741582
return http.StatusServiceUnavailable, "overloaded_error", "Upstream service overloaded, please retry later"
15751583
case 500, 502, 503, 504:
1576-
return http.StatusBadGateway, "upstream_error", "Upstream service temporarily unavailable"
1584+
return http.StatusBadGateway, "api_error", "Upstream service temporarily unavailable"
15771585
default:
1578-
return http.StatusBadGateway, "upstream_error", "Upstream request failed"
1586+
return http.StatusBadGateway, "api_error", "Upstream request failed"
15791587
}
15801588
}
15811589

15821590
// handleStreamingAwareError handles errors that may occur after streaming has started
15831591
func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) {
1592+
message = service.SanitizeClientErrorMessage(message)
15841593
if streamStarted {
15851594
// /v1/responses 的严格 SDK(Codex CLI)要求终止事件必须属于
15861595
// response.completed/failed/incomplete/cancelled 集合。
@@ -1700,7 +1709,7 @@ func (h *GatewayHandler) errorResponse(c *gin.Context, status int, errType, mess
17001709
"type": "error",
17011710
"error": gin.H{
17021711
"type": errType,
1703-
"message": message,
1712+
"message": service.SanitizeClientErrorMessage(message),
17041713
},
17051714
})
17061715
}
@@ -1917,11 +1926,11 @@ func sendMockInterceptStream(c *gin.Context, model string, interceptType Interce
19171926

19181927
switch interceptType {
19191928
case InterceptTypeSuggestionMode:
1920-
msgID = "msg_mock_suggestion"
1929+
msgID = generateRealisticMsgID()
19211930
outputTokens = 1
19221931
textDeltas = []string{""} // 空内容
19231932
default: // InterceptTypeWarmup
1924-
msgID = "msg_mock_warmup"
1933+
msgID = generateRealisticMsgID()
19251934
outputTokens = 2
19261935
textDeltas = []string{"New", " Conversation"}
19271936
}
@@ -1980,7 +1989,7 @@ func sendMockInterceptResponse(c *gin.Context, model string, interceptType Inter
19801989

19811990
switch interceptType {
19821991
case InterceptTypeSuggestionMode:
1983-
msgID = "msg_mock_suggestion"
1992+
msgID = generateRealisticMsgID()
19841993
text = ""
19851994
outputTokens = 1
19861995
stopReason = "end_turn"
@@ -1990,7 +1999,7 @@ func sendMockInterceptResponse(c *gin.Context, model string, interceptType Inter
19901999
outputTokens = 1
19912000
stopReason = "max_tokens" // max_tokens=1 探测请求的 stop_reason 应为 max_tokens
19922001
default: // InterceptTypeWarmup
1993-
msgID = "msg_mock_warmup"
2002+
msgID = generateRealisticMsgID()
19942003
text = "New Conversation"
19952004
outputTokens = 2
19962005
stopReason = "end_turn"

backend/internal/handler/openai_gateway_handler.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -936,14 +936,15 @@ func (h *OpenAIGatewayHandler) anthropicErrorResponse(c *gin.Context, status int
936936
"type": "error",
937937
"error": gin.H{
938938
"type": errType,
939-
"message": message,
939+
"message": service.SanitizeClientErrorMessage(message),
940940
},
941941
})
942942
}
943943

944944
// anthropicStreamingAwareError handles errors that may occur during streaming,
945945
// using Anthropic SSE error format.
946946
func (h *OpenAIGatewayHandler) anthropicStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) {
947+
message = service.SanitizeClientErrorMessage(message)
947948
if streamStarted {
948949
flusher, ok := c.Writer.(http.Flusher)
949950
if ok {
@@ -1817,22 +1818,23 @@ func (h *OpenAIGatewayHandler) handleFailoverExhaustedSimple(c *gin.Context, sta
18171818
func (h *OpenAIGatewayHandler) mapUpstreamError(statusCode int) (int, string, string) {
18181819
switch statusCode {
18191820
case 401:
1820-
return http.StatusBadGateway, "upstream_error", "Upstream authentication failed, please contact administrator"
1821+
return http.StatusBadGateway, "server_error", "Upstream authentication failed, please contact administrator"
18211822
case 403:
1822-
return http.StatusBadGateway, "upstream_error", "Upstream access forbidden, please contact administrator"
1823+
return http.StatusBadGateway, "server_error", "Upstream access forbidden, please contact administrator"
18231824
case 429:
18241825
return http.StatusTooManyRequests, "rate_limit_error", "Upstream rate limit exceeded, please retry later"
18251826
case 529:
1826-
return http.StatusServiceUnavailable, "upstream_error", "Upstream service overloaded, please retry later"
1827+
return http.StatusServiceUnavailable, "server_error", "Upstream service overloaded, please retry later"
18271828
case 500, 502, 503, 504:
1828-
return http.StatusBadGateway, "upstream_error", "Upstream service temporarily unavailable"
1829+
return http.StatusBadGateway, "server_error", "Upstream service temporarily unavailable"
18291830
default:
1830-
return http.StatusBadGateway, "upstream_error", "Upstream request failed"
1831+
return http.StatusBadGateway, "server_error", "Upstream request failed"
18311832
}
18321833
}
18331834

18341835
// handleStreamingAwareError handles errors that may occur after streaming has started
18351836
func (h *OpenAIGatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) {
1837+
message = service.SanitizeClientErrorMessage(message)
18361838
if streamStarted {
18371839
// /v1/responses 的严格 SDK(Codex CLI)要求终止事件必须属于
18381840
// response.completed/failed/incomplete/cancelled 集合。
@@ -1921,7 +1923,7 @@ func (h *OpenAIGatewayHandler) errorResponse(c *gin.Context, status int, errType
19211923
c.JSON(status, gin.H{
19221924
"error": gin.H{
19231925
"type": errType,
1924-
"message": message,
1926+
"message": service.SanitizeClientErrorMessage(message),
19251927
},
19261928
})
19271929
}

backend/internal/handler/openai_gateway_handler_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ func TestOpenAIEnsureForwardErrorResponse_ResponsesRouteAfterWrittenEmitsRespons
215215
assert.Contains(t, body, ":\n\n", "earlier ping bytes preserved")
216216
assert.Contains(t, body, "event: response.failed\n", "appended a Responses terminal event")
217217
assert.Contains(t, body, `"type":"response.failed"`)
218-
assert.Contains(t, body, `"code":"upstream_error"`)
218+
assert.Contains(t, body, `"code":"server_error"`) // 伪装:upstream_error 映射为 server_error
219219
assert.Contains(t, body, "Upstream request failed")
220220
}
221221

backend/internal/handler/stream_error_event.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ func mapResponsesErrorCode(errType string) string {
156156
case "authentication_error":
157157
return "authentication_failed"
158158
case "upstream_error":
159-
return "upstream_error"
159+
return "server_error" // 伪装:不暴露中转层错误类型,映射为原生 server_error
160160
case "server_error", "api_error", "":
161161
return "server_error"
162162
default:

0 commit comments

Comments
 (0)