# 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:`)」之後,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 可以: ```javascript fetch('http://127.0.0.1:3721/api/models/upload', { method: 'POST', body: maliciousModelFormData, }); ``` **v1 的 CORS middleware**(`server/internal/api/middleware.go:9-29`): ```go 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(沒有憑證) - `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` 若被設計成觸發掃描)仍會執行。解決:**副作用操作一律 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` 整檔覆寫: ```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` header(relay 功能在 M1 已砍) - 加 `Vary: Origin` 給 CDN / proxy 快取正確性(雖然我們沒走 CDN) ### 4.2 單元測試 新增 `server/internal/api/middleware_test.go`: ```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)也不會出事: ```go // 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: ```go 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`) ```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.0` toggle(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. 待確認 1. **開發模式下的 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'` — 既有程式碼應該已經處理過。 2. **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 確認。 3. **`requireSameOriginOrNoOrigin` 是否要 apply 在 WS 上** — WebSocket upgrade 是 HTTP GET,middleware 會看到。但 gorilla upgrader 自己會先 CheckOrigin。兩層都做等於雙保險,但也可能導致 edge case。M8-8 實測,若正常就留著。