依 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>
13 KiB
v2/cors-security.md — CORS + 安全邊界
所屬:TDD v2 §2.4 決策依據:三方共識 #5(CORS 限制 127.0.0.1/localhost)、R5-1(維持 127.0.0.1 綁定,不做 LAN) 對應 milestone:M8-8
1. 目的
當使用者模式從「Wails WebView 內的 UI(origin wails://)」變成「瀏覽器 tab 內的 UI(origin 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 middleware(server/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.com 和 Access-Control-Allow-Credentials: true,CORS 放行。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(沒有憑證)nullOrigin — 本地 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 若被設計成觸發掃描)仍會執行。解決:副作用操作一律 POST(server 現況已做到)。GET 只用於讀資料。
CSRF 風險:POST simple request(content-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-Tokenheader(relay 功能在 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())
實作備註:requireSameOriginOrNoOrigin 和 CORSMiddleware 看似重複,但邏輯不同:
- CORSMiddleware 管「要不要回 ACA* headers」
- requireSameOriginOrNoOrigin 管「要不要執行 handler」
前者是瀏覽器層的防線,後者是 server 層的。兩層都擋才夠 defensive。
5. WebSocket Origin check
Gin 的 WS upgrader(gorilla/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-origin(Origin 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-origin),v2 改為明確白名單 + 同 hostname。
6. 綁定 interface 維持 127.0.0.1(R5-1)
現況 visiona-local/app.go:468 明寫 --host 127.0.0.1,server main.go 會以此為 http.Server.Addr 啟動。v2 完全不動這個設定。
不做:
--host 0.0.0.0toggle(R5-1 明確否決 LAN mode)- Auth token / bearer / session(127.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 MB,v2 維持)
- Path traversal:
filepath.Clean+ 確認不含..(UploadVideo用os.CreateTemp沒有此問題;UploadModel在custom-models/底下,v1 已做 sanitize,v2 增加測試 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. 待確認
- 開發模式下的 Next.js dev server —
frontend/在pnpm dev跑時是http://localhost:3000,打後端http://127.0.0.1:3721會送Origin: http://localhost:3000。白名單會放行(localhost 在名單),OK。要確認所有 API 呼叫的 CORS credentials flag 是否要設'include'— 既有程式碼應該已經處理過。 - SSR / static export 下是否有 Origin header — Next.js static export 的 page 在瀏覽器直接 fetch,Origin header 會是當前頁面的 origin(即
http://127.0.0.1:3721,等於 same-origin)— 但實務上 same-origin 不送 Origin header。驗收時用 Chrome devtools Network tab 確認。 requireSameOriginOrNoOrigin是否要 apply 在 WS 上 — WebSocket upgrade 是 HTTP GET,middleware 會看到。但 gorilla upgrader 自己會先 CheckOrigin。兩層都做等於雙保險,但也可能導致 edge case。M8-8 實測,若正常就留著。