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