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 可以執行(畢竟 `
`、`