實作 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>
240 lines
9.2 KiB
Go
240 lines
9.2 KiB
Go
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 可以執行(畢竟 `<img>`、`<script>` tag
|
||
// 也會送 GET),擋掉反而可能影響 same-origin 的 sub-resource。瀏覽器層的保護
|
||
// 是「不讓 JS 讀回應」,已足夠。對副作用操作我們強制走 POST + 不在白名單時 403。
|
||
func CORSMiddleware() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
origin := c.GetHeader("Origin")
|
||
method := c.Request.Method
|
||
|
||
// Same-origin 請求:瀏覽器 same-origin 不送 Origin,這條走最快路徑。
|
||
if origin == "" {
|
||
if method == http.MethodOptions {
|
||
c.AbortWithStatus(http.StatusNoContent)
|
||
return
|
||
}
|
||
c.Next()
|
||
return
|
||
}
|
||
|
||
// 雲端白名單 origin(ADR-019):完整 origin 精確比對,獨立於 loopback。
|
||
if isAllowedCloudOrigin(origin) {
|
||
c.Header("Access-Control-Allow-Origin", origin)
|
||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||
c.Header("Access-Control-Allow-Headers", "Content-Type, X-Visiona-Local-Token")
|
||
// M3:雲端 origin 一律 false——用 header 帶 token、不需 cookie,
|
||
// 避免無謂讓瀏覽器願意帶 credential 而擴大 CSRF / 憑證面。
|
||
c.Header("Access-Control-Allow-Credentials", "false")
|
||
c.Header("Access-Control-Max-Age", "600")
|
||
c.Header("Vary", "Origin")
|
||
|
||
if method == http.MethodOptions {
|
||
// PNA(ADR-019 §2.5,必做):preflight 帶
|
||
// Access-Control-Request-Private-Network: true 且通過白名單 → 回 PNA header。
|
||
// 防未來 Chrome 把 PNA 從 warning 升為 blocking 時舊版 agent 無預警壞掉。
|
||
if c.GetHeader("Access-Control-Request-Private-Network") == "true" {
|
||
c.Header("Access-Control-Allow-Private-Network", "true")
|
||
}
|
||
c.AbortWithStatus(http.StatusNoContent)
|
||
return
|
||
}
|
||
c.Next()
|
||
return
|
||
}
|
||
|
||
// loopback 白名單 origin:沿用 M8-8 既有行為(Allow-Credentials: true)。
|
||
if isAllowedOrigin(origin) {
|
||
c.Header("Access-Control-Allow-Origin", origin)
|
||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Visiona-Local-Token")
|
||
c.Header("Access-Control-Allow-Credentials", "true")
|
||
c.Header("Access-Control-Max-Age", "600")
|
||
c.Header("Vary", "Origin")
|
||
|
||
if method == http.MethodOptions {
|
||
// loopback 直連也可能帶 PNA preflight(同機不同 port 屬 private network)。
|
||
if c.GetHeader("Access-Control-Request-Private-Network") == "true" {
|
||
c.Header("Access-Control-Allow-Private-Network", "true")
|
||
}
|
||
c.AbortWithStatus(http.StatusNoContent)
|
||
return
|
||
}
|
||
c.Next()
|
||
return
|
||
}
|
||
|
||
// 非白名單 Origin
|
||
// - state-changing 方法 → 403(嚴格擋)
|
||
// - GET/HEAD → 執行但不回 ACA*(瀏覽器層擋)
|
||
if method == http.MethodOptions ||
|
||
method == http.MethodPost ||
|
||
method == http.MethodPut ||
|
||
method == http.MethodDelete ||
|
||
method == http.MethodPatch {
|
||
c.AbortWithStatus(http.StatusForbidden)
|
||
return
|
||
}
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
// HostGuard 是 DNS rebinding 的獨立第二道防護(ADR-019 §2.5 M2,必做)。
|
||
//
|
||
// 檢查 Host header(去 port 後)必須 ∈ {127.0.0.1, localhost, ::1},
|
||
// 否則 400 Bad Request。與 CORS 正交:CORS 擋 Origin、HostGuard 擋 Host。
|
||
//
|
||
// 套用範圍:
|
||
// - 所有 /api/local/*(含 WP-2 新增的 /api/local/media/upload/*)
|
||
// - 舊 tunnel-path media route(/api/media/upload/*)——關舊 route 的殘留面。
|
||
//
|
||
// 為什麼 tunnel 轉發不受影響:tunnel client 轉發到本地 server 時
|
||
// req.URL.Host = 127.0.0.1:<port>(client.go),Host header 本就是 loopback,通過。
|
||
//
|
||
// DNS rebinding 情境:攻擊者把 evil.com 重綁到 127.0.0.1,
|
||
// fetch('http://evil.com:<port>/...') 實際打到本機、但 Host header 為
|
||
// evil.com:<port> ≠ loopback → 被 400 擋下。
|
||
func HostGuard() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
if !isLoopbackHost(c.Request.Host) {
|
||
c.AbortWithStatus(http.StatusBadRequest)
|
||
return
|
||
}
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
// isLoopbackHost 判斷 Host header(可能含 port)的 hostname 是否為 loopback。
|
||
//
|
||
// net.SplitHostPort 在無 port 時回 error,此時退回原字串當 hostname。
|
||
// IPv6 的 "[::1]:port" 經 SplitHostPort 會得到 "::1"(去掉方括號),
|
||
// 故 loopbackHostnames 存的是 "::1" 而非 "[::1]"。
|
||
func isLoopbackHost(host string) bool {
|
||
if host == "" {
|
||
return false
|
||
}
|
||
h, _, err := net.SplitHostPort(host)
|
||
if err != nil {
|
||
// 無 port(如 "localhost")或格式異常 → 退回原字串比對。
|
||
h = host
|
||
}
|
||
h = strings.ToLower(strings.TrimSpace(h))
|
||
// 去掉 IPv6 可能殘留的方括號(無 port 的 "[::1]" 這類邊界情況)。
|
||
h = strings.TrimPrefix(h, "[")
|
||
h = strings.TrimSuffix(h, "]")
|
||
return loopbackHostnames[h]
|
||
}
|