jim800121chen 8cd5751ce3 feat(local-tool): M8 重構 — Wails 控制台 + 瀏覽器 Web UI(R5 決策)
依 R5 五輪決策把 visionA-local 從「Wails 內嵌 Next.js」重構為「Wails
本機伺服器控制台 + 瀏覽器 Web UI」模式(類比 Docker Desktop / Ollama)。

程式碼變動
  - M8-1 砍 yt-dlp 全套(後端 resolver / URL handler / 前端 URL tab /
    Makefile vendor / installer / bootstrap / CI workflow,-555 行)
  - M8-2 砍 Mock 模式全套(driver/mock、mock_camera、Settings runtimeMode、
    VISIONA_MOCK 環境變數,-528 行)
  - M8-3 ffmpeg 從 GPL 切換到 LGPL 混合方案:Windows/Linux 用 BtbN 現成
    LGPL binary,macOS 自 build minimal decoder-only 進 git
    (vendor/ffmpeg/macos/ffmpeg 5.7MB + ffprobe 5.6MB,比 GPL 版省 85% 空間)
  - M8-4 Wails Server Controller:state machine、log ring buffer 2000 行、
    preferences.json atomic write、boot-id、Gin SkipPaths、shutdown 7+1 秒、
    notify_*.go 三平台 OS 通知、watchServer 改 Error state 不 os.Exit
  - M8-4b 啟動階段管線 R5-E:6 階段進度 event、20s soft / 60s hard timeout、
    stage 5/6 skip 規則、sentinel file、RestartStartupSequence 5 步驟
  - M8-5 Wails 控制台 vanilla HTML/JS/CSS(9 檔 ~2012 行)取代 M7-B splash:
    state 視覺、log panel、startup progress panel、Stage 6 manual CTA
    pulse、shutdown modal、Settings、Dark Mode、i18n 中英雙語
  - M8-6 上傳影片副檔名擴充(mp4/avi/mov/mpeg/mpg)
  - M8-7 Web UI Server Offline Overlay(role=alertdialog + focus trap +
    wsEverConnected 容錯 + Page Visibility)
  - M8-8 CORS middleware(127.0.0.1/localhost only + suffix attack 防護)+
    ws/origin.go 獨立 WebSocket CheckOrigin 避 package cycle
  - MAJ-4 server:shutdown-imminent WebSocket broadcast 機制
    (/ws/system endpoint + notifyShutdownImminent helper)
  - M8-9 Boot-ID + 瀏覽器 tab 自動重連(sessionStorage loop guard)

品質
  - ~105+ 新 unit test + race detector (-count=2) 全綠
  - 10 個 milestone 全部通過 Reviewer 審查
  - 三方 v2 + v2.1 文件(PRD / Design Spec / TDD)+ 交叉互審紀錄
    收錄在 .autoflow/

交付前待處理(M8-10)
  - 重跑 make payload-macos 把舊 GPL 77MB binary 換成新 LGPL
  - 三平台 end-to-end build 驗證

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:57:54 +08:00

13 KiB
Raw Permalink Blame History

v2/cors-security.md — CORS + 安全邊界

所屬TDD v2 §2.4 決策依據:三方共識 #5CORS 限制 127.0.0.1/localhost、R5-1維持 127.0.0.1 綁定,不做 LAN 對應 milestoneM8-8


1. 目的

當使用者模式從「Wails WebView 內的 UIorigin wails://)」變成「瀏覽器 tab 內的 UIorigin http://127.0.0.1:<port>」之後server 暴露在任何本機瀏覽器程序的 CORS 攻擊面下。必須明確限定哪些 Origin 可以跨來源存取 API關掉潛在的 CSRF / 資料竊取入口。


2. 攻擊場景(為什麼要做)

使用者在 Chrome 開了:

  • Tab A: http://127.0.0.1:3721/ ← visionA-local Web UI
  • Tab B: https://evil.example.com/ ← 某個有問題的網站

Tab B 的 JS 可以:

fetch('http://127.0.0.1:3721/api/models/upload', {
    method: 'POST',
    body: maliciousModelFormData,
});

v1 的 CORS middlewareserver/internal/api/middleware.go:9-29

if origin != "" {
    c.Header("Access-Control-Allow-Origin", origin)  // 回聲 Origin
}
c.Header("Access-Control-Allow-Credentials", "true")

→ Tab B 的惡意 fetch 會收到 Access-Control-Allow-Origin: https://evil.example.comAccess-Control-Allow-Credentials: trueCORS 放行。Tab B 可以讀回應、可以帶 cookie、可以送 POST。

這在 v1 的 Wails WebView 模式下不是問題Wails 內的 origin 是 wails://),但 v2 把業務 UI 搬到瀏覽器後變成真實威脅。


3. 新 CORS 政策

3.1 白名單

只允許以下 Origin 的跨來源請求:

  • http://127.0.0.1:*(任何 port
  • http://localhost:*
  • http://[::1]:*IPv6 loopback罕見但完整

不允許

  • https://... — 瀏覽器連本機不可能是 https沒有憑證
  • null Origin — 本地 HTML file 或某些 sandbox iframe 會送 null不信任
  • 其他任何 hostname

3.2 OPTIONS 預檢

  • 來自白名單 Origin → 正常回 200 + 完整 ACA* headers
  • 非白名單 Origin → 回 403 Forbidden不回 Access-Control-Allow-Origin

3.3 非預檢請求simple request例如 GET without custom header

瀏覽器不會送 OPTIONS直接送請求。這種請求 server 會執行 handler即使 Origin 不在白名單),但回應 header 沒有 Access-Control-Allow-Origin瀏覽器 JS 讀不到回應。這是 CORS 的基本行為。

然而 GET 造成的副作用(例如 GET /api/devices/scan 若被設計成觸發掃描)仍會執行。解決:副作用操作一律 POSTserver 現況已做到。GET 只用於讀資料。

CSRF 風險POST simple requestcontent-type application/x-www-form-urlencoded / multipart/form-data / text/plain)不會觸發 OPTIONS 預檢。但 visionA-local 所有 POST handler 都吃 JSON 或 multipart 的特定 field,且 v2 會額外要求所有 POST / PUT / DELETE handler 拒絕非白名單 Origin(見 §4.3),進一步關掉漏洞。


4. 實作

4.1 修改 server/internal/api/middleware.go

整檔覆寫:

package api

import (
	"net/http"
	"net/url"
	"strings"

	"github.com/gin-gonic/gin"
)

// allowedHosts 定義 CORS 白名單的 hostname。
// 任何 port 都允許scheme 只允許 http本機不可能是 https
var allowedHosts = map[string]bool{
	"127.0.0.1": true,
	"localhost": true,
	"[::1]":     true,
	"::1":       true,
}

// isAllowedOrigin 判斷 origin header 是否屬於白名單。
// 合法例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
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 僅允許 127.0.0.1/localhost/::1 任意 port 的跨來源請求。
// 非白名單 Origin 的 OPTIONS 預檢會被擋下;其他方法會正常執行 handler
// 但回應沒有 Access-Control-Allow-Origin header瀏覽器讀不到 body。
// 為了保守state-changing 方法POST/PUT/DELETE會明確回 403 當 Origin 非白名單。
func CORSMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		origin := c.GetHeader("Origin")
		method := c.Request.Method

		// Same-origin 請求Origin header 為空)一律放行 — 瀏覽器不會在 same-origin 送 Origin
		if origin == "" {
			if method == http.MethodOptions {
				c.AbortWithStatus(http.StatusNoContent)
				return
			}
			c.Next()
			return
		}

		if !isAllowedOrigin(origin) {
			// 非白名單 Origin
			// 1. OPTIONS → 403不回 ACA*
			// 2. state-changing 方法 → 403
			// 3. GET/HEAD → 執行 handler但不回 ACA*,瀏覽器 JS 讀不到 body
			if method == http.MethodOptions ||
				method == http.MethodPost ||
				method == http.MethodPut ||
				method == http.MethodDelete ||
				method == http.MethodPatch {
				c.AbortWithStatus(http.StatusForbidden)
				return
			}
			// GET / HEAD執行但不設 ACA*
			c.Next()
			return
		}

		// 白名單 Origin回完整 ACA* headers
		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")
		c.Header("Access-Control-Allow-Credentials", "true")
		c.Header("Vary", "Origin")

		if method == http.MethodOptions {
			c.AbortWithStatus(http.StatusNoContent)
			return
		}
		c.Next()
	}
}

diff 要點 vs v1

  • 新增 allowedHosts + isAllowedOrigin 白名單檢查
  • 原本「回聲 Origin」改為「只回白名單 Origin」
  • state-changing 方法POST/PUT/DELETE/PATCH若 Origin 非白名單,直接 403
  • 砍掉 X-Relay-Token headerrelay 功能在 M1 已砍)
  • Vary: Origin 給 CDN / proxy 快取正確性(雖然我們沒走 CDN

4.2 單元測試

新增 server/internal/api/middleware_test.go

func TestIsAllowedOrigin(t *testing.T) {
	cases := []struct {
		origin string
		want   bool
	}{
		{"http://127.0.0.1:3721", true},
		{"http://localhost:3721", true},
		{"http://localhost", true},
		{"http://[::1]:3721", true},
		{"http://evil.example.com", false},
		{"https://127.0.0.1:3721", false},
		{"http://192.168.1.5:3721", false},
		{"null", false},
		{"", false},
		{"http://127.0.0.1.evil.com", 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)
		}
	}
}

M8-8 驗收時跑 go test ./server/internal/api/... -run TestIsAllowedOrigin,全部通過才算完成。

4.3 Pre-handler origin check二道防線

router.go 的 state-changing 路由註冊處額外掛一層 origin check確保即使 CORSMiddleware 有 bug例如某條 handler skip 了 middleware也不會出事

// router.go 新增
func requireSameOriginOrNoOrigin() gin.HandlerFunc {
	return func(c *gin.Context) {
		origin := c.GetHeader("Origin")
		if origin == "" {
			c.Next()
			return
		}
		if !isAllowedOrigin(origin) {
			c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
				"success": false,
				"error":   gin.H{"code": "FORBIDDEN", "message": "origin not allowed"},
			})
			return
		}
		c.Next()
	}
}

掛在 /api/* group

api := r.Group("/api")
api.Use(requireSameOriginOrNoOrigin())

實作備註requireSameOriginOrNoOriginCORSMiddleware 看似重複,但邏輯不同:

  • CORSMiddleware 管「要不要回 ACA* headers」
  • requireSameOriginOrNoOrigin 管「要不要執行 handler」

前者是瀏覽器層的防線,後者是 server 層的。兩層都擋才夠 defensive。


5. WebSocket Origin check

Gin 的 WS upgradergorilla/websocket)需要自行設定 CheckOrigin。

5.1 現況

檢查 server/internal/api/ws/ 下的所有 handler 建立 upgrader 的地方。

5.2 新增 helper新 file server/internal/api/ws/origin.go

package ws

import (
	"net/http"
	"net/url"
	"strings"
)

// CheckOrigin 決定 WebSocket upgrade 是否允許。
// 與 HTTP CORS 白名單一致http://127.0.0.1:* / http://localhost:* / http://[::1]:*
// 與 same-originOrigin header 為空或等於 Host
func CheckOrigin(r *http.Request) bool {
	origin := r.Header.Get("Origin")
	if origin == "" {
		return true // same-origin
	}
	u, err := url.Parse(origin)
	if err != nil {
		return false
	}
	if u.Scheme != "http" {
		return false
	}
	host := strings.ToLower(u.Hostname())
	return host == "127.0.0.1" || host == "localhost" || host == "::1"
}

把所有 WS handler 的 upgrader CheckOrigin field 指向這個 func。

5.3 掃清單M8-8 執行時 grep

grep -rn 'websocket.Upgrader' /Users/jimchen/visionA/local-tool/server/internal/api/ws/

預期:每一個 upgrader 都要掛 CheckOrigin: ws.CheckOrigin。現況 v1 沒有明確設gorilla 預設為 same-originv2 改為明確白名單 + 同 hostname。


6. 綁定 interface 維持 127.0.0.1R5-1

現況 visiona-local/app.go:468 明寫 --host 127.0.0.1server main.go 會以此為 http.Server.Addr 啟動。v2 完全不動這個設定。

不做

  • --host 0.0.0.0 toggleR5-1 明確否決 LAN mode
  • Auth token / bearer / session127.0.0.1 only 下沒必要see v1/risks-and-mitigations.md 的分析)

7. 資料驗證邊界

v1 既有的驗證全部保留v2 沒有變更:

  • 檔案上傳大小限制
  • 檔案類型白名單v2 擴充為 .mp4/.avi/.mov/.mpeg/.mpg
  • Gin struct binding + validator tag各 handler
  • server/internal/model/ 的 .nef 檔案簽名檢查(若有)

新增:

  • 檢查 Content-Type 是否為 multipart/form-data(已有,但 v2 要 explicit 防呆)
  • 檢查檔案大小上限v1 是 100 MBv2 維持)
  • Path traversalfilepath.Clean + 確認不含 ..UploadVideoos.CreateTemp 沒有此問題;UploadModelcustom-models/ 底下v1 已做 sanitizev2 增加測試 case

8. 無 Auth token

本機單人使用R5-1 + 127.0.0.1 only不導入任何 auth。v2 保留 v1 的「單人無認證」模型。

不做

  • Basic auth / bearer token
  • Login / logout / session
  • 單機版的 TLS沒意義

9. 驗收條件

檢查 指令 預期
白名單 127.0.0.1 POST 可過 curl -X POST http://127.0.0.1:3721/api/devices/scan -H 'Origin: http://127.0.0.1:9999' 200 + Access-Control-Allow-Origin: http://127.0.0.1:9999
白名單 localhost GET 可過 curl http://127.0.0.1:3721/api/models -H 'Origin: http://localhost:3000' 200 + ACA header
非白名單 GET 執行但無 ACA curl -v http://127.0.0.1:3721/api/models -H 'Origin: http://evil.com' 200 ACA header
非白名單 POST 403 curl -X POST http://127.0.0.1:3721/api/devices/scan -H 'Origin: http://evil.com' 403
非白名單 OPTIONS 403 curl -X OPTIONS http://127.0.0.1:3721/api/models -H 'Origin: http://evil.com' 403
null Origin 擋 curl -X POST http://127.0.0.1:3721/api/devices/scan -H 'Origin: null' 403
https Origin 擋 curl -X POST http://127.0.0.1:3721/api/devices/scan -H 'Origin: https://127.0.0.1:3721' 403
Same-origin 正常 curl http://127.0.0.1:3721/api/models(不帶 Origin 200 + 正常 response
WS 白名單可連 websocat -H='Origin: http://127.0.0.1:9999' ws://127.0.0.1:3721/ws/devices/events 連上
WS 非白名單擋 websocat -H='Origin: http://evil.com' ws://127.0.0.1:3721/ws/devices/events 403 / 拒絕 upgrade
單元測試 cd server && go test ./internal/api/... -run TestIsAllowedOrigin PASS

10. 待確認

  1. 開發模式下的 Next.js dev serverfrontend/pnpm dev 跑時是 http://localhost:3000,打後端 http://127.0.0.1:3721 會送 Origin: http://localhost:3000。白名單會放行localhost 在名單OK。要確認所有 API 呼叫的 CORS credentials flag 是否要設 'include' — 既有程式碼應該已經處理過。
  2. SSR / static export 下是否有 Origin header — Next.js static export 的 page 在瀏覽器直接 fetchOrigin header 會是當前頁面的 originhttp://127.0.0.1:3721,等於 same-origin— 但實務上 same-origin 不送 Origin header。驗收時用 Chrome devtools Network tab 確認。
  3. requireSameOriginOrNoOrigin 是否要 apply 在 WS 上 — WebSocket upgrade 是 HTTP GETmiddleware 會看到。但 gorilla upgrader 自己會先 CheckOrigin。兩層都做等於雙保險但也可能導致 edge case。M8-8 實測,若正常就留著。