jim800121chen c9f7eab682 fix(proxy): tunnel 轉發剝除 Origin header 修「開始推論」403
雲端瀏覽器點「開始推論」→ 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) <noreply@anthropic.com>
2026-07-09 04:27:49 +08:00

118 lines
4.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package api
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"visiona-backend/internal/session"
)
// TestNewProxyHandler_NoForwarder 驗證沒注入 Forwarder 時回 501。
func TestNewProxyHandler_NoForwarder(t *testing.T) {
r := gin.New()
r.Use(RequestIDMiddleware())
g := r.Group("/api")
g.POST("/devices/scan", newProxyHandler(Deps{}, proxyOptions{}))
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/devices/scan", nil))
assert.Equal(t, http.StatusNotImplemented, w.Code)
}
// TestNewProxyHandler_TunnelDisconnected 驗證沒 session 時回 502 TUNNEL_DISCONNECTED。
//
// 這裡用 fakeSessionStoreList 回空)+ 非 nil forwarder 的「半個」 proxy handler
// 因為 nil forwarder 的 path 會先 return 501見上方 test。我們用真實 forwarder
// 但不 dial — 直接在 pickActiveSessionToken 回 ErrSessionNotFound 就攔掉了。
//
// Phase 0.7 security fix C1handler 強制要求 UserContext用 injectStaticUserContext
// 顯式注入避免回 500見 .autoflow/05-implementation/review/phase-0.7-security-audit.md
func TestNewProxyHandler_TunnelDisconnected(t *testing.T) {
r := gin.New()
r.Use(RequestIDMiddleware())
r.Use(injectStaticUserContext("demo-user", ""))
g := r.Group("/api")
g.POST("/devices/scan", newProxyHandler(Deps{
SessionStore: &fakeSessionStore{}, // List 回空
Forwarder: session.NewForwarder("http://localhost:0", nil),
}, proxyOptions{}))
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/devices/scan", nil))
assert.Equal(t, http.StatusBadGateway, w.Code)
assert.Contains(t, w.Body.String(), ErrCodeTunnelDisconnect)
}
// TestPickActiveSessionToken 驗證 helper 回第一筆 match 的 session。
func TestPickActiveSessionToken(t *testing.T) {
store := &fakeSessionStore{
sessions: []*session.Summary{
{Token: "t-other", UserID: "other"},
{Token: "t-me", UserID: "demo-user"},
},
}
tok, err := pickActiveSessionToken(context.Background(), store, "demo-user", nil)
assert.NoError(t, err)
assert.Equal(t, "t-me", tok)
}
// TestPickActiveSessionToken_Empty 驗證沒 session 時回 ErrSessionNotFound。
func TestPickActiveSessionToken_Empty(t *testing.T) {
store := &fakeSessionStore{}
_, 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-hopConnection 等)被略過
// - 其他 headerContent-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"))
}