package api import ( "net" "net/http" "net/url" "os" "strings" "github.com/gin-gonic/gin" ) // allowedHosts 定義 loopback CORS 白名單的 hostname。 // 任何 port 都允許,scheme 只允許 http(本機不可能是 https)。 // // M8-8(TDD v2/cors-security.md §3.1): // v2 模式下 UI 改在使用者瀏覽器中跑,server 同時暴露給其他瀏覽器分頁, // 必須限定 cross-origin 來源在本機 loopback,避免惡意網站透過 CORS 攻擊。 // // ADR-019 §2.5:此 loopback 舊規則「保留不動」——不因開放雲端 origin 而變更。 var allowedHosts = map[string]bool{ "127.0.0.1": true, "localhost": true, "[::1]": true, "::1": true, } // loopbackHostnames 是 Host header 驗證(ADR-019 §2.5 M2)允許的 hostname 集合。 // 與 allowedHosts 概念不同:allowedHosts 比對「Origin header 的 hostname」, // 這裡比對「Host header 的 hostname」——DNS rebinding 防護的獨立第二道。 var loopbackHostnames = map[string]bool{ "127.0.0.1": true, "localhost": true, "::1": true, } // cloudOrigins 是 ADR-019 §2.5 M3 的雲端 origin 白名單—— // 存「完整 origin 字串」(scheme+host+port 全等),比對時逐字精確相等。 // // 刻意獨立於 loopback 的 isAllowedOrigin(hostname-only + 任意 port + 只收 http): // - 若沿用 hostname-only,會變成「該網域任意 port 都放行」,攻擊面過大。 // - 若放寬 scheme 檢查,會讓 http/https 混用可繞過。 // // 故雲端 origin 一律走「完整 origin 精確比對」,來源 env VISIONA_CLOUD_ORIGINS。 // 於 init 時載入一次(server 生命週期內固定)。 var cloudOrigins = loadCloudOrigins(os.Getenv("VISIONA_CLOUD_ORIGINS")) // loadCloudOrigins 解析逗號分隔的完整 origin 字串,回傳精確比對用的 set。 // // 每個項目做 TrimSpace,過濾空字串。不做任何 hostname/port 拆解—— // 白名單存的就是完整 origin,比對時整串相等才通過(ADR-019 §2.5 M3)。 func loadCloudOrigins(raw string) map[string]bool { set := make(map[string]bool) if raw == "" { return set } for _, part := range strings.Split(raw, ",") { origin := strings.TrimSpace(part) if origin != "" { set[origin] = true } } return set } // isAllowedCloudOrigin 判斷 Origin 是否為雲端白名單 origin(完整 origin 精確比對)。 func isAllowedCloudOrigin(origin string) bool { if origin == "" { return false } return cloudOrigins[origin] } // isAllowedOrigin 判斷 Origin header 是否屬於 loopback 白名單。 // // 合法例:http://127.0.0.1:3721 / http://localhost:3721 / http://[::1]:3721 // 不合法例:https://127.0.0.1:3721 / http://evil.com / null / http://192.168.1.5:3721 // // 注意: // - 空字串視為非白名單(呼叫端會自行決定 same-origin 路徑)。 // - "null"(local file、某些 sandboxed iframe)一律拒絕。 // - 只允許 http scheme,本機不會有 https。 // // ADR-019:此函式維持 loopback 舊邏輯不動;雲端 origin 走 isAllowedCloudOrigin。 func isAllowedOrigin(origin string) bool { if origin == "" || origin == "null" { return false } u, err := url.Parse(origin) if err != nil { return false } if u.Scheme != "http" { return false } host := strings.ToLower(u.Hostname()) return allowedHosts[host] } // CORSMiddleware 處理跨來源請求,區分 loopback 與雲端 origin 兩條路徑。 // // 行為(M8-8 / TDD v2/cors-security.md §4.1 + ADR-019 §2.5): // // 1. Origin header 為空 → same-origin(瀏覽器 same-origin 不送 Origin)→ 直接放行; // 若是 OPTIONS 預檢則回 204 即停(避免帶 ACA* 給沒人看的請求)。 // 2. Origin 在 loopback 白名單 → 回完整 ACA* headers(含 Allow-Credentials: true, // 沿用 M8-8 既有行為);OPTIONS → 204;其他方法 → 繼續執行 handler。 // 3. Origin 在雲端白名單(ADR-019)→ 回 ACA* headers,但 // Allow-Credentials: false(本路徑用 X-Visiona-Local-Token header 帶 token、不需 cookie)、 // Allow-Headers 含 X-Visiona-Local-Token、Max-Age: 600、 // 並在 preflight 帶 PNA 請求時回 Access-Control-Allow-Private-Network: true。 // 4. Origin 都不在白名單: // - state-changing 方法(POST/PUT/DELETE/PATCH/OPTIONS)→ 403 Forbidden,不回 ACA*。 // - 簡單讀取(GET/HEAD)→ 執行 handler 但不回 ACA*,瀏覽器 JS 讀不到 body。 // // 為什麼 GET/HEAD 不直接擋:CORS 的設計就是讓 GET 可以執行(畢竟 ``、`