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")) +}