雲端瀏覽器點「開始推論」→ 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>
118 lines
4.5 KiB
Go
118 lines
4.5 KiB
Go
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。
|
||
//
|
||
// 這裡用 fakeSessionStore(List 回空)+ 非 nil forwarder 的「半個」 proxy handler;
|
||
// 因為 nil forwarder 的 path 會先 return 501(見上方 test)。我們用真實 forwarder
|
||
// 但不 dial — 直接在 pickActiveSessionToken 回 ErrSessionNotFound 就攔掉了。
|
||
//
|
||
// Phase 0.7 security fix C1:handler 強制要求 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-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"))
|
||
}
|