From c9f7eab682064ba7e5f1a37cfc7f918726398d07 Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Thu, 9 Jul 2026 04:27:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(proxy):=20tunnel=20=E8=BD=89=E7=99=BC?= =?UTF-8?q?=E5=89=9D=E9=99=A4=20Origin=20header=20=E4=BF=AE=E3=80=8C?= =?UTF-8?q?=E9=96=8B=E5=A7=8B=E6=8E=A8=E8=AB=96=E3=80=8D403?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 雲端瀏覽器點「開始推論」→ POST /api/devices/{id}/inference/start, 瀏覽器自動帶的 Origin(stage 網域)經 nginx→api-server proxy→tunnel 透傳到 local agent,被 local agent CORSMiddleware(只允許 loopback Origin)擋回 403。local agent 的 CORS 是為「本機模式 UI 直連」設計, 不該套用在經 tunnel 中繼的 server-to-server 請求。 修法:api-server 的 copyProxyRequestHeaders 剝除 Origin header(比照 既有 Authorization 過濾、EqualFold case-insensitive),local agent 收到 無 Origin → 走 same-origin 放行路徑。 安全邊界:copyProxyRequestHeaders 全 codebase 只用於 tunnel 轉發 (proxy.go newProxyHandler),不影響 api-server 面向瀏覽器的 CORSMiddleware(另一套 gin-contrib/cors)。Reviewer 0C/0M 通過、 Security APPROVE(剝 Origin 不開 CSRF 面,真正閘門在 api-server AuthMiddleware + SameSite=Lax cookie)。 +2 test(Origin/Authorization 剝除 + case-insensitive)6/6 PASS。 Co-Authored-By: Claude Opus 4.8 (1M context) --- visionA-backend/internal/api/proxy.go | 17 ++++++++ visionA-backend/internal/api/proxy_test.go | 48 ++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/visionA-backend/internal/api/proxy.go b/visionA-backend/internal/api/proxy.go index 699c4e4..c4b062b 100644 --- a/visionA-backend/internal/api/proxy.go +++ b/visionA-backend/internal/api/proxy.go @@ -185,6 +185,9 @@ func writeTunnelError(c *gin.Context, err error) { // TE, Trailers, Transfer-Encoding, Upgrade // // 這些由 Forwarder / underlying conn 自動處理,不該 blind copy。 +// +// **此函式只用在 tunnel proxy 轉發路徑**(newProxyHandler → local agent 中繼), +// 不影響 api-server 自身面向瀏覽器的 CORSMiddleware(那是另一套 gin-contrib/cors)。 func copyProxyRequestHeaders(src, dst http.Header) { for name, values := range src { if isHopByHopHeader(name) { @@ -195,6 +198,20 @@ func copyProxyRequestHeaders(src, dst http.Header) { if strings.EqualFold(name, "Authorization") { continue } + // Origin header 必須剝掉。原因: + // 雲端瀏覽器打「開始推論」等 state-changing POST 時會自動帶上 stage 網域的 + // Origin(如 http://stage-9527.innovedus.com)。此 Origin 經 nginx → api-server + // → tunnel 一路原樣透傳到 local agent,local agent 的 CORSMiddleware 只把 + // 127.0.0.1/localhost/::1 列白名單,非白名單 + state-changing 方法會直接 403。 + // 但 tunnel 中繼請求並非瀏覽器對 local agent 的 cross-origin 請求,不該被 + // local agent 的 CORS 邏輯攔。剝掉 Origin 後 local agent 走「無 Origin → + // same-origin 放行」的快速路徑(見 local-agent middleware.go CORSMiddleware)。 + // + // 安全性:這只發生在 api-server → local agent 這段 server-to-server 中繼, + // 瀏覽器 → api-server 那段仍由 api-server 自己的 CORSMiddleware 把關,不受影響。 + if strings.EqualFold(name, "Origin") { + continue + } for _, v := range values { dst.Add(name, v) } diff --git a/visionA-backend/internal/api/proxy_test.go b/visionA-backend/internal/api/proxy_test.go index ef1db99..4e97a81 100644 --- a/visionA-backend/internal/api/proxy_test.go +++ b/visionA-backend/internal/api/proxy_test.go @@ -67,3 +67,51 @@ func TestPickActiveSessionToken_Empty(t *testing.T) { _, err := pickActiveSessionToken(context.Background(), store, "demo-user", nil) assert.ErrorIs(t, err, session.ErrSessionNotFound) } + +// TestCopyProxyRequestHeaders 驗證 header 複製規則: +// - Origin 被剝掉(避免 local agent CORSMiddleware 對 tunnel 中繼請求回 403) +// - Authorization 被剝掉(local agent 雛形無對應 auth 系統) +// - hop-by-hop(Connection 等)被略過 +// - 其他 header(Content-Type、custom X-From-Api)原樣保留 +// - header 名稱比對 case-insensitive(大小寫變形也要剝) +func TestCopyProxyRequestHeaders(t *testing.T) { + src := http.Header{} + src.Set("Origin", "http://stage-9527.innovedus.com") + src.Set("Authorization", "Bearer secret-token") + src.Set("Connection", "keep-alive") + src.Set("Content-Type", "application/json") + src.Set("X-From-Api", "1") + // 多值 header 也要保留全部值 + src.Add("X-Multi", "a") + src.Add("X-Multi", "b") + + dst := http.Header{} + copyProxyRequestHeaders(src, dst) + + // 被剝掉的 + assert.Empty(t, dst.Get("Origin"), "Origin 應被剝掉") + assert.Empty(t, dst.Get("Authorization"), "Authorization 應被剝掉") + assert.Empty(t, dst.Get("Connection"), "hop-by-hop Connection 應被略過") + + // 被保留的 + assert.Equal(t, "application/json", dst.Get("Content-Type"), "Content-Type 應保留") + assert.Equal(t, "1", dst.Get("X-From-Api"), "custom header 應保留") + assert.Equal(t, []string{"a", "b"}, dst.Values("X-Multi"), "多值 header 應完整保留") +} + +// TestCopyProxyRequestHeaders_OriginCaseInsensitive 明確驗證大小寫變形的 Origin +// 仍被剝掉(Go http.Header 會 canonicalize、但用直接寫入 map 模擬非 canonical 情況)。 +func TestCopyProxyRequestHeaders_OriginCaseInsensitive(t *testing.T) { + // 直接寫入非 canonical key,繞過 http.Header.Set 的 canonicalization, + // 模擬 range 迴圈碰到 "ORIGIN" / "origin" 這類變形時 EqualFold 仍要命中。 + src := http.Header{ + "ORIGIN": {"http://stage-9527.innovedus.com"}, + "origin": {"http://192.168.0.130"}, + } + dst := http.Header{} + copyProxyRequestHeaders(src, dst) + + assert.Empty(t, dst.Get("Origin"), "大小寫變形的 Origin 也應被剝掉") + assert.Empty(t, dst.Values("ORIGIN")) + assert.Empty(t, dst.Values("origin")) +}