實作 ADR-019 混合路徑:影片/圖片/批次的檔案上傳改由瀏覽器同機直連 local-agent localhost endpoint(繞過雲端 tunnel),控制面 + MJPEG 結果 + 推論 WS 仍走 tunnel。解決大檔頻寬雙倍 + nginx 100M + 300s timeout。 三條 stream(全數過 reviewer + security code-level 複審 APPROVED): local-agent(Go): - CORS 雲端 origin 完整精確比對 + Allow-Credentials:false + HostGuard(loopback) + PNA header(middleware.go) - 新 route /api/local/media/upload/*(一律要 token、不看 Origin,關 C1 後門) - one-time token store(crypto/rand、TTL 120s、綁 deviceId、single-flight consume、 上限 32→429;200 goroutine -race 綠) - GET /api/local/hello(回 salted SHA-256 serialHashes、最小揭露) + POST /api/local/issue-token(Host-based) - LocalUploadGuard(token+size 驗證放 FormFile 前);video≤500MB / batch 合計 80MB → 413;stopActivePipeline + batch 生命週期 temp 檔清理 cloud(visionA-backend): - POST /api/devices/:serial/local-upload-ticket(OIDC + 裝置歸屬 + 經 tunnel 轉發 issue-token;IDOR-safe、錯誤不洩漏) frontend(visionA-frontend): - lib/local-agent.ts(port 探測 3721-3740 並發+快取、Web Crypto serial hash 比對 同機判定、uploadToLocalAgent 通用函式) - validateBatchFiles 合計大小檢查(MAX_BATCH_TOTAL_BYTES=80MB,消 50×19MB 撞 413 地雷) 回歸:ADR-019 相關 270 測試全綠、既有 tunnel 路徑未被打斷、無 regression。 既有 tunnel(無 Origin)不要求 token(C1 route 分離相容性保證)。 Refs: ADR-019。WP-0(PNA 實機)/WP-4(影片分頁接線)下一批。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
453 lines
14 KiB
Go
453 lines
14 KiB
Go
package api
|
||
|
||
import (
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
func init() {
|
||
gin.SetMode(gin.TestMode)
|
||
}
|
||
|
||
// TestIsAllowedOrigin 驗證 CORS 白名單判斷邏輯(M8-8 / TDD §4.2)。
|
||
func TestIsAllowedOrigin(t *testing.T) {
|
||
cases := []struct {
|
||
origin string
|
||
want bool
|
||
}{
|
||
// 白名單合法情境
|
||
{"http://127.0.0.1:3721", true},
|
||
{"http://127.0.0.1", true},
|
||
{"http://localhost:3000", true},
|
||
{"http://localhost:8080", true},
|
||
{"http://localhost", true},
|
||
{"http://[::1]:3721", true},
|
||
{"http://LOCALHOST:9999", true}, // hostname 應大小寫不敏感
|
||
|
||
// scheme 不對
|
||
{"https://127.0.0.1:3721", false},
|
||
{"https://localhost:3000", false},
|
||
{"ws://127.0.0.1:3721", false},
|
||
|
||
// hostname 不在白名單
|
||
{"http://192.168.1.5:3721", false},
|
||
{"http://example.com", false},
|
||
{"http://malicious.local", false},
|
||
{"http://127.0.0.1.evil.com", false}, // suffix 攻擊
|
||
{"http://evil-127.0.0.1.com", false},
|
||
|
||
// 特殊情境
|
||
{"", false},
|
||
{"null", false},
|
||
{"http://", false},
|
||
{"not-a-url", false},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
got := isAllowedOrigin(tc.origin)
|
||
if got != tc.want {
|
||
t.Errorf("isAllowedOrigin(%q) = %v, want %v", tc.origin, got, tc.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// newTestRouter 建一台只掛 CORSMiddleware 的最小 router,用於測試 middleware 行為。
|
||
func newTestRouter() *gin.Engine {
|
||
r := gin.New()
|
||
r.Use(CORSMiddleware())
|
||
r.GET("/api/ping", func(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||
})
|
||
r.POST("/api/do", func(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||
})
|
||
return r
|
||
}
|
||
|
||
// TestCORSMiddleware_AllowedOriginGET:白名單 Origin 的 GET 應回 200 且帶 ACA header。
|
||
func TestCORSMiddleware_AllowedOriginGET(t *testing.T) {
|
||
r := newTestRouter()
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
|
||
req.Header.Set("Origin", "http://127.0.0.1:3000")
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200", w.Code)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://127.0.0.1:3000" {
|
||
t.Errorf("ACA-Origin = %q, want http://127.0.0.1:3000", got)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
||
t.Errorf("ACA-Credentials = %q, want true", got)
|
||
}
|
||
if got := w.Header().Get("Vary"); got != "Origin" {
|
||
t.Errorf("Vary = %q, want Origin", got)
|
||
}
|
||
}
|
||
|
||
// TestCORSMiddleware_LocalhostAllowed:localhost 任意 port 都應放行。
|
||
func TestCORSMiddleware_LocalhostAllowed(t *testing.T) {
|
||
r := newTestRouter()
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
|
||
req.Header.Set("Origin", "http://localhost:8080")
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200", w.Code)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:8080" {
|
||
t.Errorf("ACA-Origin = %q, want http://localhost:8080", got)
|
||
}
|
||
}
|
||
|
||
// TestCORSMiddleware_DisallowedOriginPOST:非白名單 Origin 的 POST 必須 403。
|
||
func TestCORSMiddleware_DisallowedOriginPOST(t *testing.T) {
|
||
r := newTestRouter()
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/api/do", nil)
|
||
req.Header.Set("Origin", "https://example.com")
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusForbidden {
|
||
t.Fatalf("status = %d, want 403", w.Code)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||
t.Errorf("非白名單不應回 ACA-Origin,got %q", got)
|
||
}
|
||
}
|
||
|
||
// TestCORSMiddleware_DisallowedOriginGET:非白名單 GET 應該執行 handler 但不回 ACA。
|
||
func TestCORSMiddleware_DisallowedOriginGET(t *testing.T) {
|
||
r := newTestRouter()
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
|
||
req.Header.Set("Origin", "http://malicious.local")
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200 (handler 仍執行,瀏覽器層擋讀取)", w.Code)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||
t.Errorf("非白名單不應回 ACA-Origin,got %q", got)
|
||
}
|
||
}
|
||
|
||
// TestCORSMiddleware_PreflightAllowed:白名單 Origin 的 OPTIONS preflight 應回 204 + 完整 headers。
|
||
func TestCORSMiddleware_PreflightAllowed(t *testing.T) {
|
||
r := newTestRouter()
|
||
|
||
req := httptest.NewRequest(http.MethodOptions, "/api/do", nil)
|
||
req.Header.Set("Origin", "http://127.0.0.1:9999")
|
||
req.Header.Set("Access-Control-Request-Method", "POST")
|
||
req.Header.Set("Access-Control-Request-Headers", "Content-Type")
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusNoContent {
|
||
t.Fatalf("status = %d, want 204", w.Code)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://127.0.0.1:9999" {
|
||
t.Errorf("ACA-Origin = %q, want http://127.0.0.1:9999", got)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Methods"); got == "" {
|
||
t.Errorf("ACA-Methods 不應為空")
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Headers"); got == "" {
|
||
t.Errorf("ACA-Headers 不應為空")
|
||
}
|
||
}
|
||
|
||
// TestCORSMiddleware_PreflightDisallowed:非白名單 OPTIONS preflight 應 403,不回 ACA。
|
||
func TestCORSMiddleware_PreflightDisallowed(t *testing.T) {
|
||
r := newTestRouter()
|
||
|
||
req := httptest.NewRequest(http.MethodOptions, "/api/do", nil)
|
||
req.Header.Set("Origin", "http://evil.com")
|
||
req.Header.Set("Access-Control-Request-Method", "POST")
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusForbidden {
|
||
t.Fatalf("status = %d, want 403", w.Code)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||
t.Errorf("非白名單不應回 ACA-Origin,got %q", got)
|
||
}
|
||
}
|
||
|
||
// TestCORSMiddleware_SameOrigin:沒帶 Origin(same-origin)應放行。
|
||
func TestCORSMiddleware_SameOrigin(t *testing.T) {
|
||
r := newTestRouter()
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200", w.Code)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||
t.Errorf("same-origin 不應回 ACA-Origin,got %q", got)
|
||
}
|
||
}
|
||
|
||
// ----- ADR-019 WP-1:雲端 origin 精確比對 + PNA + HostGuard -----
|
||
|
||
// TestLoadCloudOrigins 驗證 VISIONA_CLOUD_ORIGINS 解析(逗號分隔、TrimSpace、過濾空字串)。
|
||
func TestLoadCloudOrigins(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
raw string
|
||
want map[string]bool
|
||
}{
|
||
{"empty", "", map[string]bool{}},
|
||
{"single", "https://stage-9527.innovedus.com:9527",
|
||
map[string]bool{"https://stage-9527.innovedus.com:9527": true}},
|
||
{"multi with spaces", " https://a.com:443 , http://192.168.0.130:9527 ",
|
||
map[string]bool{"https://a.com:443": true, "http://192.168.0.130:9527": true}},
|
||
{"trailing comma", "http://localhost:3000,,",
|
||
map[string]bool{"http://localhost:3000": true}},
|
||
}
|
||
for _, tc := range cases {
|
||
got := loadCloudOrigins(tc.raw)
|
||
if len(got) != len(tc.want) {
|
||
t.Errorf("%s: len = %d, want %d (%v)", tc.name, len(got), len(tc.want), got)
|
||
continue
|
||
}
|
||
for k := range tc.want {
|
||
if !got[k] {
|
||
t.Errorf("%s: missing origin %q in %v", tc.name, k, got)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestIsAllowedCloudOrigin_ExactMatch 驗證雲端 origin 必須 scheme+host+port 全等(M3)。
|
||
// 關鍵:不可像 loopback 那樣 hostname-only + 任意 port。
|
||
func TestIsAllowedCloudOrigin_ExactMatch(t *testing.T) {
|
||
// 直接注入測試白名單,避免依賴環境變數。
|
||
saved := cloudOrigins
|
||
cloudOrigins = map[string]bool{
|
||
"https://stage-9527.innovedus.com:9527": true,
|
||
"http://192.168.0.130:9527": true,
|
||
}
|
||
defer func() { cloudOrigins = saved }()
|
||
|
||
cases := []struct {
|
||
origin string
|
||
want bool
|
||
}{
|
||
// 完全相符
|
||
{"https://stage-9527.innovedus.com:9527", true},
|
||
{"http://192.168.0.130:9527", true},
|
||
|
||
// 同 host 不同 port → 不通過(證明不是 hostname-only)
|
||
{"https://stage-9527.innovedus.com:8080", false},
|
||
{"https://stage-9527.innovedus.com", false},
|
||
{"http://192.168.0.130:8080", false},
|
||
|
||
// 同 host 不同 scheme → 不通過(證明不放寬 scheme)
|
||
{"http://stage-9527.innovedus.com:9527", false},
|
||
{"https://192.168.0.130:9527", false},
|
||
|
||
// 其他
|
||
{"", false},
|
||
{"null", false},
|
||
{"https://evil.com:9527", false},
|
||
{"https://stage-9527.innovedus.com:9527.evil.com", false},
|
||
}
|
||
for _, tc := range cases {
|
||
if got := isAllowedCloudOrigin(tc.origin); got != tc.want {
|
||
t.Errorf("isAllowedCloudOrigin(%q) = %v, want %v", tc.origin, got, tc.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// newCloudTestRouter 建一台掛 CORSMiddleware 的 router,並注入測試用雲端白名單。
|
||
func newCloudTestRouter(t *testing.T) *gin.Engine {
|
||
t.Helper()
|
||
saved := cloudOrigins
|
||
cloudOrigins = map[string]bool{"https://cloud.example.com:9527": true}
|
||
t.Cleanup(func() { cloudOrigins = saved })
|
||
return newTestRouter()
|
||
}
|
||
|
||
// TestCORSMiddleware_CloudOriginPOST:雲端白名單 origin 的 POST 應放行 + Credentials:false。
|
||
func TestCORSMiddleware_CloudOriginPOST(t *testing.T) {
|
||
r := newCloudTestRouter(t)
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/api/do", nil)
|
||
req.Header.Set("Origin", "https://cloud.example.com:9527")
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200", w.Code)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://cloud.example.com:9527" {
|
||
t.Errorf("ACA-Origin = %q, want cloud origin", got)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "false" {
|
||
t.Errorf("ACA-Credentials = %q, want false (M3)", got)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(got, "X-Visiona-Local-Token") {
|
||
t.Errorf("ACA-Headers = %q, 必須含 X-Visiona-Local-Token", got)
|
||
}
|
||
}
|
||
|
||
// TestCORSMiddleware_CloudPreflightPNA:雲端 origin preflight 帶 PNA request → 回 PNA header + Max-Age。
|
||
func TestCORSMiddleware_CloudPreflightPNA(t *testing.T) {
|
||
r := newCloudTestRouter(t)
|
||
|
||
req := httptest.NewRequest(http.MethodOptions, "/api/do", nil)
|
||
req.Header.Set("Origin", "https://cloud.example.com:9527")
|
||
req.Header.Set("Access-Control-Request-Method", "POST")
|
||
req.Header.Set("Access-Control-Request-Private-Network", "true")
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusNoContent {
|
||
t.Fatalf("status = %d, want 204", w.Code)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Private-Network"); got != "true" {
|
||
t.Errorf("ACA-Private-Network = %q, want true (PNA 必做)", got)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Max-Age"); got != "600" {
|
||
t.Errorf("Max-Age = %q, want 600", got)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "false" {
|
||
t.Errorf("ACA-Credentials = %q, want false", got)
|
||
}
|
||
}
|
||
|
||
// TestCORSMiddleware_CloudPreflightNoPNARequest:preflight 未帶 PNA request → 不回 PNA header。
|
||
func TestCORSMiddleware_CloudPreflightNoPNARequest(t *testing.T) {
|
||
r := newCloudTestRouter(t)
|
||
|
||
req := httptest.NewRequest(http.MethodOptions, "/api/do", nil)
|
||
req.Header.Set("Origin", "https://cloud.example.com:9527")
|
||
req.Header.Set("Access-Control-Request-Method", "POST")
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusNoContent {
|
||
t.Fatalf("status = %d, want 204", w.Code)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Private-Network"); got != "" {
|
||
t.Errorf("未帶 PNA request 不應回 PNA header,got %q", got)
|
||
}
|
||
}
|
||
|
||
// TestCORSMiddleware_NonWhitelistedCloudPortPOST:同 host 但不在白名單的 port → 403。
|
||
func TestCORSMiddleware_NonWhitelistedCloudPortPOST(t *testing.T) {
|
||
r := newCloudTestRouter(t)
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/api/do", nil)
|
||
req.Header.Set("Origin", "https://cloud.example.com:8080") // 不同 port
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusForbidden {
|
||
t.Fatalf("status = %d, want 403(不同 port 不應通過精確比對)", w.Code)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||
t.Errorf("不應回 ACA-Origin,got %q", got)
|
||
}
|
||
}
|
||
|
||
// TestCORSMiddleware_LoopbackCredentialsUnchanged:loopback origin 仍回 Credentials:true(M8-8 保留不動)。
|
||
func TestCORSMiddleware_LoopbackCredentialsUnchanged(t *testing.T) {
|
||
r := newTestRouter()
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
|
||
req.Header.Set("Origin", "http://127.0.0.1:3721")
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, req)
|
||
|
||
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
||
t.Errorf("loopback ACA-Credentials = %q, want true(ADR-019 保留 loopback 舊規則)", got)
|
||
}
|
||
}
|
||
|
||
// ----- HostGuard -----
|
||
|
||
// newHostGuardRouter 建一台掛 HostGuard 的 router。
|
||
func newHostGuardRouter() *gin.Engine {
|
||
r := gin.New()
|
||
r.POST("/api/media/upload/video", HostGuard(), func(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||
})
|
||
return r
|
||
}
|
||
|
||
// TestHostGuard 驗證 Host header 必須 = loopback,否則 400。
|
||
func TestHostGuard(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
host string
|
||
wantCode int
|
||
}{
|
||
{"127.0.0.1 with port", "127.0.0.1:3721", http.StatusOK},
|
||
{"localhost with port", "localhost:3721", http.StatusOK},
|
||
{"localhost no port", "localhost", http.StatusOK},
|
||
{"127.0.0.1 no port", "127.0.0.1", http.StatusOK},
|
||
{"ipv6 loopback with port", "[::1]:3721", http.StatusOK},
|
||
{"uppercase LOCALHOST", "LOCALHOST:3721", http.StatusOK},
|
||
// DNS rebinding:Host 為攻擊者網域 → 400
|
||
{"evil domain", "evil.com:3721", http.StatusBadRequest},
|
||
{"evil domain no port", "evil.com", http.StatusBadRequest},
|
||
{"lan ip", "192.168.0.130:9527", http.StatusBadRequest},
|
||
{"public ip", "8.8.8.8:80", http.StatusBadRequest},
|
||
// suffix 攻擊
|
||
{"loopback suffix attack", "127.0.0.1.evil.com:3721", http.StatusBadRequest},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
r := newHostGuardRouter()
|
||
req := httptest.NewRequest(http.MethodPost, "/api/media/upload/video", nil)
|
||
req.Host = tc.host
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, req)
|
||
if w.Code != tc.wantCode {
|
||
t.Errorf("Host=%q → %d, want %d", tc.host, w.Code, tc.wantCode)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestIsLoopbackHost 直接單元測試 host 判斷邏輯。
|
||
func TestIsLoopbackHost(t *testing.T) {
|
||
cases := []struct {
|
||
host string
|
||
want bool
|
||
}{
|
||
{"127.0.0.1:3721", true},
|
||
{"127.0.0.1", true},
|
||
{"localhost:8080", true},
|
||
{"localhost", true},
|
||
{"[::1]:3721", true},
|
||
{"::1", true},
|
||
{"", false},
|
||
{"evil.com", false},
|
||
{"evil.com:3721", false},
|
||
{"192.168.0.130:9527", false},
|
||
{"127.0.0.1.evil.com:80", false},
|
||
}
|
||
for _, tc := range cases {
|
||
if got := isLoopbackHost(tc.host); got != tc.want {
|
||
t.Errorf("isLoopbackHost(%q) = %v, want %v", tc.host, got, tc.want)
|
||
}
|
||
}
|
||
}
|