visionA/local-agent/server/internal/api/middleware_test.go
jim800121chen 9031153553 feat(adr-019): 影片/圖片/批次上傳走同機 localhost 直連 local-agent
實作 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>
2026-07-30 12:32:26 +08:00

453 lines
14 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 (
"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_LocalhostAllowedlocalhost 任意 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-Origingot %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-Origingot %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-Origingot %q", got)
}
}
// TestCORSMiddleware_SameOrigin沒帶 Originsame-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-Origingot %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_CloudPreflightNoPNARequestpreflight 未帶 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 headergot %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-Origingot %q", got)
}
}
// TestCORSMiddleware_LoopbackCredentialsUnchangedloopback origin 仍回 Credentials:trueM8-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 trueADR-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 rebindingHost 為攻擊者網域 → 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)
}
}
}