docs(arch): ADR-019 影片/圖片/批次改走同機 localhost 直連(契約定稿)

新增 ADR-019:影片/圖片/批次上傳從經雲端 tunnel 改為同機瀏覽器直連
local-agent 的 localhost endpoint,修補 design-doc §5.3「大檔不走 tunnel」
原則未套用到 media 的邏輯漏洞。採混合路徑(上傳走 localhost、控制面 +
MJPEG 結果 + 推論 WS 仍走 tunnel)。

已完成 security pre-implementation review(1 Critical + 3 Major)+ architect
依審查修正契約 + security confirm-only 複審 APPROVED。契約含:
- C1: route 分離(新 route /api/local/media/upload/* 一律要 token、不看 Origin),
  消除「無 Origin 免 token」後門
- M2: Host header 驗證 = loopback 升為必做(DNS rebinding 緩解)
- M3: CORS 雲端 origin 完整字串精確比對 + Allow-Credentials: false
- 議題1: serial 回 SHA-256(visiona-local-v1‖serial) 雜湊、移除 agentVersion
- M1: media size 上限(video≤500MB / batch 合計 80MB)+ temp 檔清理升為必做

ADR 維持 Proposed,待實作完成 + code-level security 複審後轉 Accepted。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-07-30 03:31:16 +08:00
parent c595bb8b91
commit 4c962dfec1
3 changed files with 357 additions and 3 deletions

View File

@ -0,0 +1,240 @@
# ADR-019: 影片 / 圖片 / 批次上傳走同機 localhost 直連 local-agent混合路徑
## 狀態
Proposed尚未實作。**已依 security review`.autoflow/05-implementation/review/adr-019-security-review.md`修正契約C1 / M1 / M2 / M3 / 議題1待 security confirm-only 複審 + 實作完成後轉 Accepted。** C1 定案採方案 Aroute 分離),見 §2.4。
## 日期
2026-07-24
## 作者
Architect Agent
---
## 1. 背景與範圍 (Context)
### 1.1 觸發問題
visionA 的 media 上傳(影片 / 圖片 / 批次)目前**全部經雲端 tunnel forward**(見 `api/api-spec.md §6`「Camera / Media — 與 local-tool 相同,全部透過 tunnel forward」。對影片這種大檔此路徑有三個實質問題
| 問題 | 說明 | 證據 |
|------|------|------|
| **頻寬雙倍佔用** | 500MB 影片經 tunnel = 上傳本機→雲端500MB + 結果下行雲端→本機500MB ≈ 1000MB 總傳輸,兩次都佔用使用者本地頻寬 | 第一輪資料流調查 §5.3 |
| **撞 nginx 上限** | stage nginx 兩處 `client_max_body_size 100M`500MB 影片直接 413 | `docker/nginx.stage.conf:97, 417` |
| **api-server 300s timeout + stage 磁碟落地** | 即使調大 nginx10 Mbps 上行需 400s > 300s timeout`proxy_request_buffering` 預設 on每次上傳在 stage 落地 500MB 暫存檔 | `visionA-backend/internal/api/proxy.go:32, 89-94``camera.go:47` |
### 1.2 被忽略的邏輯漏洞(本 ADR 修補的核心)
`design-doc.md §5.3`(模型上傳)早已建立原則:
> **「模型檔可達百 MB走 tunnel = 占用使用者本地頻寬兩次(上傳→雲端→下載)」**,故模型上傳**不走 tunnel**(改 presigned URL 瀏覽器直連)。
**這個「大檔不走 tunnel」的原則從未套用到 media 上傳**——影片同樣是大檔、同樣佔用雙倍頻寬,卻仍走 tunnel。本 ADR 把同一原則延伸到 media修補此文件內部邏輯漏洞。
### 1.3 有利前提(讀 code 後確認)
- local-agent 已有完整的 loopback HTTP serverGin強制綁 `127.0.0.1:<port>``local-agent/server/main.go:298-305``config.go:34-35,45`)。
- `POST /api/media/upload/video`(及 image / batch**route 已存在於 local-agent路徑與雲端完全一致**`local-agent/server/internal/api/router.go:90-94`。handler`camera_handler.go:232-336`)與 tunnel **完全解耦**tunnel client 只做透明轉發,`internal/tunnel/client.go:337-371`)——瀏覽器直連走的是同一個 handler、同一 code path**handler 零改動**。
### 1.4 使用者已拍板的前提
- **跨機不存在**:使用者確認「都是同機操作」,故非同機情境採「停用該分頁 + 明確提示」,不做 tunnel fallback。這是**產品定位**,不是能力缺口。
- **瀏覽器範圍**:僅需支援 Chrome / EdgeChromium 系。Safari 不在支援範圍。
- **認證必做**:本機 endpoint 經既有 tunnel 下發 one-time token。
---
## 2. 決策 (Decision)
我們將讓瀏覽器在**同機情境**下,直接 POST 影片 / 圖片 / 批次到 local-agent 的 **localhost HTTP endpoint**,繞過雲端 tunnel。**控制面token 取得與結果面MJPEG 串流、推論 WS仍走雲端 tunnel**——這是「上傳走本機、結果走雲端」的**混合路徑**,不是把整條資料流搬到本機。
具體包含五塊決策:
### 2.1 混合路徑(只搬「上行大檔」,控制面 / 結果面不動)
- **改走 localhost**:影片 / 圖片 / 批次的**檔案上傳** POST。
- **仍走 tunnel刻意保留**token 取得控制面、MJPEG 結果串流(`streamUrl` 是相對路徑 `/api/camera/stream`,前端用 `getApiBaseUrl()` 組雲端絕對路徑,`camera_handler.go:325``visionA-frontend/src/lib/camera.ts:35-42`)、推論結果 WS`/ws/devices/:id/inference`)。
- 理由MJPEG 結果資料量遠小於原始影片15fps down-scaled JPEG走 tunnel 成本可接受;強行把結果也搬本機需在前端維護兩套連線來源,複雜度大增。**保持「控制面 + 結果面走雲端、只有大檔上傳走 localhost」是風險最低的最小切口。**
### 2.2 範圍:影片 + 圖片 + 批次全部改走 localhost
- 三個分頁全部改走 localhost 直連(不只影片)。前端上傳抽象設計為 **endpoint 無關**`uploadToLocalAgent(path, form, options)`),三個 caller 換 path 即可,不需重構。
- **批次額外補「合計大小檢查」**:現況 `validateBatchFiles``visionA-frontend/src/lib/media.ts:301-309`)只逐張檢查 ≤20MB、**不加總**——50 張各 19MB合計 950MB會通過前端驗證後撞 413。這是已上膛的地雷須補合計上限建議 `MAX_BATCH_TOTAL_BYTES = 80MB`,留 nginx 100M 的 20% 餘裕)。
### 2.3 同機偵測 + port 探測(前端掃描候選 port + 身分驗證)
- local-agent 的 port 是**動態**的(`pickPort` 從 3721 fallback 到 3740`app.go:46-47, 1450-1468`),前端**不可寫死 3721**。
- 前端探測策略:`sessionStorage 快取``3721``37223740 並發``Promise.any` 取第一個成功者),每次 timeout 500ms。
- **探測動作本身就是同機偵測**:任一 port 回應且身分相符 = 同機;無回應 = 非同機。
- 新增專用探測 endpoint `GET /api/local/hello`(不用既有 `/api/system/health`——後者對非白名單 origin 的 GET 不回 ACA*,瀏覽器 JS 讀不到內容)。
- **身分驗證必做**`/api/local/hello` 回傳 agent 管理的裝置 serial 的 **salted SHA-256 雜湊**`SHA-256("visiona-local-v1" || serial)`,已裁決見 §5 / api-spec §6.3),前端重算比對 `selectedDevice.serialNumber` 是否相符。防止「同機跑著另一台 agent、影片被送到錯的裝置」這種靜默錯誤資料流。沿用 ADR-018 的 serial 路由慣例,不新造識別體系。
### 2.4 認證:經既有 tunnel 下發 one-time token縱深防禦
開放雲端 origin 打 local-agent 後,威脅模型從「只有本機 loopback 網頁能打」變成「同機任何網頁只要通過 origin 檢查就可能控制 Kneron 裝置」。**CORS 白名單擋不住 XSS合法 origin與 DNS rebinding繞過 DNS 解析層)**,故必須有認證作為第二道防線。
流程(契約):
```
[1] 前端進影片分頁 → 向雲端要 token經既有 OIDC 認證 + 既有 tunnel
POST /api/devices/:serial/local-upload-ticket新增雲端 endpoint
→ api-server 驗 OIDC session + 裝置歸屬 → 經 tunnel 打 local-agent
POST /api/local/issue-token → 產 token 回傳
[2] 前端掃描找到 localhost port§2.3
[3] 前端 POST 影片 → http://127.0.0.1:<port>/api/local/media/upload/video ← 新的、一律要 token 的 route
Header: X-Visiona-Local-Token: <token>
[4] local-agent 驗 token存在 / 未過期 / 未用過 / deviceId 相符)+ 驗 Host = loopback§2.5
→ 消耗 token → 內部轉呼叫既有 handlerhandler 零改動)
```
Token 設計方向(已經 security agent 審,核心設計通過,見 review 議題 2
- 生成 `crypto/rand` 32 bytes → base64url**不准 `math/rand`**TTL 120sone-time驗證後立即刪綁 deviceId記憶體 store`map + sync.Mutex`,不持久化);上限同時 32 個未使用 token防 DoS比對用 `crypto/subtle.ConstantTimeCompare`(防 timing attack
- **實作時注意項security review m2 / m3不改契約但工程師必守**consume 與 issue 各自在**單一** `Lock()` 內完成consume = 查存在 + ConstantTimeCompare + 刪除三步一次持鎖issue = 查 len<32 + 插入一次持鎖避免併發雙重消費 / 上限突破token 驗證放在讀 multipart body`FormFile`**之前** middleware 更佳避免未驗證就先收大檔放大 DoS
### 2.4.1 [C1 — 已定案:方案 A route 分離] 認證判準不得依賴攻擊者可控的 Origin
> **背景security review C1Critical blocker**:原契約訂「來自 tunnel 的請求(無 Origin header**不得**要求 token」把「有無 Origin」當成「是不是可信 tunnel 來源」的唯一判準。但 Origin header 由請求發送方完全控制,**同機任何程序curl / 惡意 App / 被入侵的其他本機服務)都能發出不帶 Origin 的 HTTP 請求到 `127.0.0.1:<port>`**local-agent 綁 loopback、同機所有程序皆可連等於「不帶 Origin 就免 token」的後門整層 token 認證被繞過。
**查證結論(讀 `local-agent/visiona-agent/internal/tunnel/client.go`**tunnel client 的 `handleStream` 把收到的 HTTP request 直接 `http.DefaultTransport.RoundTrip(req)``req.URL.Host = c.localAddr`= `127.0.0.1:<port>`,普通 HTTP over loopback見 client.go:337-355、73。tunnel **未在轉發時注入任何可辨識標記**,且 tunnel clientWails app shell module `visiona-agent`)與 HTTP server獨立子行程 module `server`)是**兩個獨立行程**。結論:**同機程序可完全偽裝成「tunnel 來的請求」**server 端無法用 request 本身區分。
**定案:採方案 Aroute 分離)**,不採方案 Btunnel 注入 process-local secret。理由
1. tunnel client 與 HTTP server 是兩個獨立行程 / 獨立 Go module見 client.go 檔頂註解),方案 B 的「process-local 共享 secret」實際上需要跨行程 IPC 傳遞 nonce脆弱且增加耦合方案 A 不需任何跨行程協調。
2. 方案 A 把「要不要 token」交給 **server 端固定的 route** 決定,不由攻擊者可控的 Origin 決定。
**契約鐵則(取代原「無 Origin 免 token」硬規則**
- **媒體上傳 endpoint 絕不能有「無任何憑證即放行」的 code path。** 刪除原「來自 tunnel 的請求(無 Origin不得要求 token」的無條件放行語意。
- **瀏覽器 localhost 直連**走**新的、一律要求 token 的 route**`POST /api/local/media/upload/{video|image|batch-images}`。此 route **不看 Origin、一律驗 token**token 無效 / 缺失 → 401並經 §2.5 Host 驗證。內部轉呼叫既有 handlerhandler 零改動)。
- **既有 tunnel 轉發路徑**維持既有 `POST /api/media/upload/{video|image|batch-images}`tunnel client 內部轉發至此,路徑不變、行為不變)。此路徑**不因本 ADR 新開放給任何雲端 origin**CORS 白名單擴充只作用於直連情境的 `/api/local/*` 行為)。
**誠實揭露的殘留風險**:因 loopback 對同機程序無隔離,同機惡意程序仍可**直接**打舊 tunnel-path route`/api/media/upload/*`)——但這是 **ADR-019 之前就存在的既有 loopback 攻擊面**(非本 ADR 新增),且舊 route 未被本 ADR 新開放給雲端 origin。緩解舊 tunnel-path route 也套用 §2.5 Host 驗證Host=loopback 才放行),把「同機直打舊 route」限制在同機、且不影響 tunnel 轉發tunnel 轉發的 Host 本就是 `127.0.0.1:<port>`)。完整消除同機程序攻擊面超出本 ADR 範圍(需 OS 層 socket 權限 / peer credential 驗證),列為 backlog。
### 2.5 CORS 白名單擴充 + Host 驗證 + PNA 緩解(全部必做)
- Origin 白名單改為可設定env `VISIONA_CLOUD_ORIGINS`,逗號分隔完整 origin**stage 需同時允許雙入口**
- `https://stage-9527.innovedus.com:9527`(公網 HTTPS`nginx.stage.conf:86-89`
- `http://192.168.0.130:9527`**內網純 HTTP** 入口,`nginx.stage.conf:411-414`
- dev`http://localhost:3000`
- loopback 舊規則(`middleware.go:17-22`)保留不動。
- **[M3 — 契約必做] 雲端 origin 採「完整 origin 精確比對」**scheme+host+port **全等**逐字比對),**不可**沿用既有 `isAllowedOrigin` 的 hostname-only + 任意 port + 放寬 scheme 邏輯(`middleware.go:41-45`)。白名單裡存的是完整 origin 字串(如 `https://stage-9527.innovedus.com:9527`),比對時整串相等才通過。原因:既有 `allowedHosts` 只比 hostname、放任任意 port若直接把雲端網域塞進去會變成「該網域任意 port 都放行」,攻擊面比預期大;放寬 scheme 若寫成「不檢查 scheme」則 `https://127.0.0.1`、`http://` 混用可繞過。故雲端 origin 必須**獨立於既有 loopback 邏輯**,走完整字串精確比對。
- **[M3 — 契約必做] `Access-Control-Allow-Credentials: false`(對雲端 origin**:本路徑用 header`X-Visiona-Local-Token`)帶 token、不需 cookie。**不可**沿用既有 middleware 對雲端 origin 回 `Allow-Credentials: true``middleware.go:97`)——那會無謂讓瀏覽器願意帶 credential、擴大 CSRF / 憑證面。雲端 origin 一律回 `false`api-spec §6.4 已列)。
- **[M2 — 契約必做,從「待審 / optional」升為「必做」] Host header 驗證 = loopback**:對所有 `/api/local/*`(含新的 `/api/local/media/upload/*`)與舊 tunnel-path media route加 Host 驗證 middleware——`Host` header去掉 port 後)必須 ∈ `{127.0.0.1, localhost, ::1}`,否則 `400 Bad Request`。這是**獨立於 CORS 的第二道**、擋 DNS rebindingrebinding 後 `Host` 為攻擊者網域 ≠ loopback → 400成本低、對本機服務屬 baseline 而非 optional。tunnel 轉發的 Host 本就是 `127.0.0.1:<port>`(見 client.go:347故不受影響。驗證邏輯契約不寫進 production code
```
host := c.Request.Host // 含 port
h, _, _ := net.SplitHostPort(host) // 去 port無 port 時退回原值)
if h ∉ {"127.0.0.1", "localhost", "::1"} → 400 Bad Request
```
- **PNA 緩解(必做,非 optional**:對帶 `Access-Control-Request-Private-Network: true` 且通過白名單的 preflight`Access-Control-Allow-Private-Network: true`。原因見 §4.3 R-1——即使只支援 Chrome/EdgeChrome 未來可能把 PNA 從 warning 升為 blocking而已安裝的舊版 local-agent 不會自動升級,此 header 現在補、成本 <30 分鐘省未來一顆不定時炸彈
- **preflight 帶 `Access-Control-Max-Age: 600`**api-spec §6.4,緩解 port 掃描的大量 preflight
---
## 3. 考慮過的替代方案 (Alternatives Considered)
| 方案 | 優點 | 缺點 | 排除原因 |
|------|------|------|---------|
| **維持 tunnel + 調大 nginx `client_max_body_size`** | 改動最小、不動前端架構 | 不解決頻寬雙倍批次分頁仍是地雷stage 每次落地 500MB 暫存檔;還需 IT 改公司邊界 nginx`STAGE-DEPLOY.md:397-401` 待辦未完成api-server 300s timeout 仍會炸 | 治標不治本,且引入「改 nginx→改 timeout→關 request buffering→改公司邊界 nginx」的連鎖與「最小改動」背道而馳 |
| **傳檔案路徑給 local-agent不傳檔案內容** | 傳輸量 ~100 bytes | 瀏覽器基於安全**拿不到真實路徑**`File.path` 已被所有現代瀏覽器移除);需在 local-agent 加業務 UI違反 `server_control.go:1023-1033`「使用者不感知 server 存在」的既有決策path traversal 風險 | 技術不可行(拿不到路徑)+ 違反既有架構方向 |
| **前端壓縮 / 抽 frame 後上傳(縮小檔案再走 tunnel** | 沿用現有 tunnel 路徑 | 失真影響推論準確度;瀏覽器端影片處理相容性差、耗時;不解決架構問題(只是把大檔變小檔) | 犧牲推論品質,且沒有真正解決頻寬 / 落地問題 |
| **localhost 直連(採用)** | 傳輸量 1000MB→loopback0.12.5s,改善約 1001000 倍);不受 nginx / timeout 約束handler 零改動 | 需同機操作local-agent 新增對外攻擊面需認證PNA 未來風險 | ✅ 採用 |
---
## 4. 後果 (Consequences)
### 4.1 正面影響
- **頻寬**500MB 影片從「1000MB 經雲端 tunnel」降為「loopback 本機傳輸」(現代 SSD 筆電 0.10.5s、較舊機器 12.5s)。
- **不受基礎設施約束**:繞過 nginx 100M 上限、api-server 300s timeout、stage 磁碟落地。
- **handler 零改動**local-agent 的 `UploadVideo` handler 一行不用改route 已存在、與 tunnel 解耦)。
- **修補文件邏輯漏洞**:把 `design-doc.md §5.3` 的「大檔不走 tunnel」原則正確延伸到 media。
- **順帶修批次地雷**:補合計大小檢查,消除 50×19MB 撞 413 的隱患。
### 4.2 負面影響與限制(接受的取捨)
- **影片 / 圖片 / 批次上傳需同機操作**:必須在執行 visionA Agent 的那台電腦上操作。非同機時該分頁停用 + 提示。**Camera即時攝影機仍支援遠端檢視**——只有「上行大檔」需同機限制。
- 依使用者拍板「跨機不存在」,此限制不影響實際使用;且現況跨機使用者本來就傳不了 >100MB413此限制是「把隱性限制變成明確提示」非新增限制。
- **不動 PRD**:使用者已明確跨機不存在,此限制在 ADR 記錄即可,不需 PM 改 PRD。
- **local-agent 新增對外攻擊面**:首次開放雲端 origin 直連 local-agent需維護 CORS 白名單 + token 認證 + PNA header 的安全性。
- **僅支援 Chromium 系Chrome / Edge**Safari 不支援(使用者已確認不需要)。
- **狀態分裂風險**:上傳走 localhost、結果走雲端 tunnel兩條路徑可能不同步見 §4.3 R-3
### 4.3 風險
| # | 風險 | 等級 | 緩解 |
|---|------|------|------|
| **R-1** | PNA 未來變硬阻擋,已安裝的舊版 local-agent 無法自動升級 → 影片功能無預警壞掉 | **高** | 第一版就加 `Access-Control-Allow-Private-Network: true`§2.5,成本 <30min |
| **R-3** | 狀態分裂:上傳走 localhost 成功,但結果 tunnel 剛好斷線 → 上傳成功卻看不到結果 | 中 | 上傳前檢查既有 `tunnel_online` 欄位(`visionA-backend/internal/api/devices.go:66-67`),離線時 disable 上傳 |
| **R-4** | 同機多個 visionA agent多裝置場景掃描命中錯的 agent | 中 | §2.3 serial 身分驗證;掃描收集所有回應再挑 serial 相符者 |
| **R-6** | 影片 / 圖片 / 批次上傳**完全無 size 上限** + temp 檔洩漏(`stopActivePipeline` 切 pipeline 時把 `videoPath=""` 但**不** `os.Remove`batch 成功路徑後也未刪),反覆上傳可磁碟 DoS配合 C1 未修時為 High | **中→高** | **[M1 — 契約必做,非查證] 見 §4.3.1** |
### 4.3.1 [M1 — 契約必做] media 上傳 size 上限 + temp 檔清理
> security review M1temp 檔洩漏**已確認為實錘**(非待查證)——`camera_handler.go``stopActivePipeline`line 535`h.videoPath = ""` 但從未對前一支影片 temp 檔 `os.Remove`batch 路徑在 `NewMultiImageSource` 成功後也未追蹤刪除。結合無 size 上限 → 反覆上傳磁碟必爆。
**契約必做項WP-2非「查證」**
- **size 上限(各自值,必做)**video / image / batch-images 各自的上傳 size 上限納入契約。
- **video 上限:與前端 90MB 對齊,硬上限不超過 500MB**security 明確要求「不要因為走 loopback 就給 1GB」——攻擊面不因 loopback 縮小)。建議實作值 = 前端 90MB正常上限server 端 `http.MaxBytesReader` 硬牆設 ≤ 500MB 作為 DoS 上界。
- image / batch 單張沿用既有逐張限制;**batch 合計上限** = `MAX_BATCH_TOTAL_BYTES = 80MB`§2.2,補 nginx 100M 的 20% 餘裕;此上限對 localhost 直連仍保留,作為資源上界)。
- 驗證時機size 檢查與 token 驗證都放在讀 multipart body`FormFile`**之前**review m3
- **temp 檔清理(必做)**`stopActivePipeline` 切換 / 停止 pipeline 時對前一支 `videoPath``os.Remove`batch 的 `MultiImageSource` 在生命週期結束時刪除其 `filePaths`。反覆上傳不得殘留 temp 檔testing 寫回歸測試驗證)。
| **R-5** | 企業防火牆 / EDR 攔截 localhost HTTP | 低 | 明確錯誤訊息讓使用者知道要檢查什麼 |
> 註:第一輪風險表中的 R-2Safari 相容性)因使用者確認僅支援 Chromium已移除。
---
## 5. security agent 裁決事項(已裁決)
依 Architect CLAUDE.md §14.3 邊界表token / secret 管理與隱私洩露屬 security agent 審查範圍。以下兩點已經 security agent 審查裁決(`.autoflow/05-implementation/review/adr-019-security-review.md` 議題 1 / 議題 2契約內容已依裁決更新
| # | 議題 | 裁決結果(已落實於本 ADR / api-spec |
|---|------|------------------|
| 1 | **`GET /api/local/hello` 回傳完整 serial 還是雜湊 serial** | **已裁決回「salted SHA-256 雜湊」,不回完整 serial。** `serialHashes[i] = SHA-256(公開固定 salt \|\| fullSerial)` 的 hexsalt 常數 = `visiona-local-v1`(前後端共用、非 server 私有,讓前端可獨立重算比對);`agentVersion` 粗化或移除(改回布林 `supportsLocalUpload: true` 已足夠hello 不回任何其他欄位(最小揭露)。契約見 api-spec §6.3。殘留風險(序號熵低 + salt 公開 → 有心攻擊者可枚舉回推security 判定「與威脅相稱、不需過度設計」(序號非 credential、控制裝置仍需 token+OIDC。 |
| 2 | **one-time token 設計是否足夠 + DNS rebinding 完整緩解** | **已裁決token 核心設計通過。** 附加要求已落實a**C1 route 分離**§2.4.1token 認證不得被無 Origin 繞過,是 token 設計的前置 blockerb**Host 驗證必做**§2.5rebinding 主防護,非 optionalc**CORS 完整 origin 精確比對 + Allow-Credentials: false**§2.5dtoken race 用 single-flight 持鎖(實作注意項 m2。upload handler **不**需在內部再綁 Origin 二次驗證CORS 已做、且避免與 C1 方案衝突)。 |
---
## 6. 本 ADR 修正 / supersede 的內容
| 文件 | 章節 | 原內容 | 修正為 |
|------|------|--------|--------|
| `api/api-spec.md` | §6 Camera / Media | 「與 local-tool 相同,**全部透過 tunnel forward**」 | 影片 / 圖片 / 批次上傳走 localhost 直連Camera 與推論結果串流仍走 tunnel見本 ADR |
| `design-doc.md` | §5.3 之後 | 「大檔不走 tunnel」原則只涵蓋模型上傳 | 新增一節說明 media 上傳套用同原則localhost 直連),修補邏輯漏洞 |
> 註:本 ADR 不 supersede 任何既有 ADR僅修正上述兩份設計文件的敘述。ADR-018serial 路由)的識別慣例被本 ADR **沿用**§2.3),無衝突。
---
## 7. 合規性
- [x] 與使用者確認方向(同機 localhost 直連 + 混合路徑 + 範圍含影片/圖片/批次 + 非同機停用 + 僅 Chromium + 認證必做)— ✅ 已裁決
- [x] **security agent 審 token / 隱私設計**§5 兩點)— ✅ 已審verdict: REQUEST CHANGES → 契約已依 C1/M1/M2/M3/議題1 修正,待 confirm-only 複審)
- [ ] backend agent 落地 local-agent CORS/PNA/token/endpoints + 雲端 ticket endpoint
- [ ] frontend agent 落地 port 探測抽象 + 影片分頁接線 + 批次合計檢查
- [ ] testing agent 回歸:既有 tunnel 路徑(無 Origin不受 token 影響跨瀏覽器Chrome/Edge驗證
- [ ] 成本影響:**無新雲端資源**(沿用現有 DB / tunnel主要是三 module 開發工時,粗估 3.35.4 人天(見 §8 WP 清單);反而降低 stage 磁碟與頻寬壓力
---
## 8. WP 工作包清單(給 Orchestrator 排程)
> 完整依賴圖 / 關鍵路徑 / work-stream 同步點見 `.autoflow/04-architecture/video-localhost-wp-breakdown.md`(個人層)。此處為摘要。
> **本清單已依 security review 更新**C1 route 分離、M1 size 上限 + temp 清理必做、M2 Host 驗證必做、M3 CORS 完整 origin 精確比對)。
| WP | 內容 | 負責 agent | 依賴 | 人時 |
|----|------|-----------|------|------|
| WP-0 | SpikeChrome/Edge 實測 `https/http 入口 → http://127.0.0.1:3721` + PNA console warning | frontend | 無 | 24 |
| WP-1 | local-agent CORS / Host / PNA雲端 origin **完整 origin 精確比對**(獨立於既有 hostname-only 邏輯,**不放寬既有 scheme 檢查**+ **`Allow-Credentials: false`(雲端 origin** + **Host header 驗證 = loopbackM2 必做,套用 `/api/local/*` 與舊 media route** + PNA header + `Access-Control-Max-Age: 600` | backendGo | 契約 §2.5 | 57 |
| WP-2 | local-agent**新 route `/api/local/media/upload/{video\|image\|batch-images}`(一律要 token、不看 Origin、內部轉呼叫既有 handler** + `/api/local/hello`(回 salted SHA-256 serialHashes + `/api/local/issue-token` + token storesingle-flight 持鎖)+ token 驗證中介(放 `FormFile` 前)+ **media size 上限video ≤500MB 硬牆 / batch 合計 80MBM1 必做)** + **temp 檔清理(`stopActivePipeline` 補 `os.Remove` + batch 生命週期刪檔M1 必做)** | backendGo | WP-1 | 913 |
| WP-3 | 前端:`lib/local-agent.ts`port 探測並發+快取+timeout、同機判定用 salted SHA-256 比對 serialHashes、`uploadToLocalAgent()` endpoint 無關通用函式;上傳目標改新 route `/api/local/media/upload/*` | frontend | 契約 §2.3/§6.3 | 610 |
| WP-4 | 前端:影片分頁接線(取 token、改上傳目標為 `/api/local/media/upload/video`、三種錯誤訊息 i18n、tunnel 離線檢查 R-3 | frontend | WP-3 | 46 |
| WP-5 | 雲端:`POST /api/devices/:serial/local-upload-ticket`(經 tunnel 轉發 issue-token | backendGo | 契約 C-1 | 24 |
| WP-6可選 | 圖片 + 批次接上 localhost 路徑 | frontend | WP-4 驗證通過 | 35 |
| WP-7獨立建議先做 | `validateBatchFiles` 加合計大小檢查 | frontend | 無 | 0.51 |
**推薦範圍WP-0..WP-5 + WP-7總計26.543 小時 ≈ 3.35.4 人天。**
**關鍵路徑(串行、決定最短工期)**:契約定稿 → WP-0 → WP-1 → WP-2 → 整合 → E2E ≈ 1422 小時。
**可平行**WP-3前端對契約 mock與 WP-1/WP-2backend完全平行WP-5雲端 ticket與 WP-1/WP-2 平行WP-7 完全獨立。建議開 23 條 work-streamfrontend / local-agent backend / cloud backend不宜再切。
---
## 9. 一句話總結
影片 / 圖片 / 批次上傳從「經雲端 tunnel頻寬雙倍 + 撞 nginx 100M + 300s timeout + stage 落地)」改為**同機瀏覽器直連 local-agent 的 localhost endpoint**handler 零改動、route 已存在),修補 `design-doc.md §5.3`「大檔不走 tunnel」原則未套用到 media 的邏輯漏洞;採**混合路徑**(上傳走 localhost、控制面 + MJPEG 結果 + 推論 WS 仍走 tunnel動態 port 用前端掃描 37213740 + serial 身分驗證偵測同機;認證用經既有 tunnel 下發的 one-time token縱深防禦擋 XSS / DNS rebindingCORS 擴充雙入口白名單 + PNA header防未來 Chrome 升級 blocking非同機停用該分頁產品定位「跨機不存在」不做 fallback。token / 隱私設計待 security agent 審。

View File

@ -217,17 +217,105 @@
## 6. Camera / Media
與 local-tool 相同,全部透過 tunnel forward
**路徑分流2026-07 起,見 ADR-019**Camera 與推論**結果**串流走雲端 tunnel forward影片 / 圖片 / 批次的**檔案上傳**改走**同機 localhost 直連 local-agent**(繞過 tunnel解決大檔頻寬雙倍 + nginx 100M + 300s timeout
### 6.1 走 tunnel forward與 local-tool 相同,不變)
### GET `/api/camera/list`
### POST `/api/camera/start`
### POST `/api/camera/stop`
### GET `/api/camera/stream` — MJPEG透過 tunnel streaming
### GET `/api/media/batch-images/:index` — 讀取批次結果(走 tunnel
### POST `/api/media/seek` — 影片 seek走 tunnel
> Camera 支援遠端(跨機)檢視,這是產品核心價值,不受同機限制。
### 6.2 走 localhost 直連 local-agent影片 / 圖片 / 批次上傳,見 ADR-019
以下**上傳** endpoint 由瀏覽器在**同機情境**下直接 POST 到 local-agent 的 loopback server`http://127.0.0.1:<port>`port 動態 ∈ 37213740**不經雲端 tunnel**。
> **[C1 — security review 定案route 分離]** 瀏覽器 localhost 直連走**新的、一律要求 token 的** route`/api/local/media/upload/*`**不看 Origin**;既有 tunnel 轉發路徑維持 `/api/media/upload/*` 不變。認證判準由 server 端固定 route 決定,**不**由攻擊者可控的 Origin 決定(原「無 Origin 免 token」規則已刪除——見 ADR-019 §2.4.1)。
#### 瀏覽器 localhost 直連 route新增、一律要 token
### POST `/api/local/media/upload/image` — 直連 local-agent
### POST `/api/local/media/upload/video` — 直連 local-agent大檔主要受益者
### POST `/api/local/media/upload/batch-images` — 直連 local-agent
**上傳請求契約(瀏覽器 → local-agent**
- Target`http://127.0.0.1:<port>/api/local/media/upload/{video|image|batch-images}`port 由前端探測取得,見下方 §6.3
- Header`X-Visiona-Local-Token: <token>`**一律要求,不看 Origin**;缺失 / 無效 → 401
- Host 驗證:`Host` header去 port必須 ∈ `{127.0.0.1, localhost, ::1}`,否則 `400`DNS rebinding 防護,見 §6.4
- Body既有 `multipart/form-data`(含 `deviceId` 欄位 + `file`),格式不變。此 route 內部轉呼叫既有 upload handlerhandler 零改動)。
- **size 上限必做ADR-019 §4.3.1**video 硬牆 ≤ 500MB前端正常上限 90MBbatch 合計 ≤ 80MB。size 與 token 驗證都在讀 body`FormFile`**之前**。
- 驗證失敗 → `401 { "success": false, "error": { "code": "LOCAL_TOKEN_INVALID" } }`
- 檔案過大 → `413 { "success": false, "error": { "code": "LOCAL_UPLOAD_TOO_LARGE" } }`
#### 既有 tunnel 轉發 route保留不變供雲端路徑
### POST `/api/media/upload/image`
### POST `/api/media/upload/video`
### POST `/api/media/upload/batch-images`
### GET `/api/media/batch-images/:index`
### POST `/api/media/seek`
> tunnel client 內部轉發至此,路徑 / 行為不變。此路徑**不因本 ADR 新開放給雲端 origin**;同機直打舊 route 仍受 §6.4 Host 驗證約束Host=loopback
> 推論**結果**MJPEG 串流 `streamUrl` / 推論 WS `/ws/devices/:id/inference`)仍走雲端 tunnel。這是「上傳走 localhost、結果走雲端」的混合路徑ADR-019 §2.1)。
### 6.3 localhost 直連的支援 endpointlocal-agent 新增,見 ADR-019
以下 endpoint 為 localhost 直連機制所需,**僅存在於 local-agent 本機 server**,不對應雲端路由:
#### GET `/api/local/hello` — 同機偵測 + 身分驗證
- Authbootstrap 需要——拿 token 前需先知道 portserial 以雜湊揭露、最小化 bootstrap 洩露,見下方裁決)
- Response 200
```json
{
"success": true,
"data": {
"serialHashes": ["<hex>", "..."],
"supportsLocalUpload": true
}
}
```
- **serialHashes 契約security review 議題 1 裁決,必守)**
- 每個元素 = `SHA-256(salt || fullSerial)`**hex** 字串lowercase hex。演算法固定 **SHA-256**(非 MD5 / SHA-1非 bcrypt/argon2——目的是「前端可重算比對」而非「防暴力還原」
- **salt = 固定公開常數 `visiona-local-v1`**(前後端共用寫死的 app-level 常數、**非** server 私有隨機值)。前端用 Web Crypto `crypto.subtle.digest('SHA-256', utf8("visiona-local-v1" + fullSerial))` 獨立重算比對。
- **明確不要做**:不得用 server 啟動時 `crypto/rand` 生成的私有 salt前端算不出、破壞比對不得 per-request 隨機 salt。
- 用途:前端掃描 37213740比對 `SHA-256("visiona-local-v1" || selectedDevice.serialNumber) ∈ serialHashes` 判定同機(沿用 ADR-018 serial 路由慣例)。
- 誠實揭露殘留風險:序號熵低 + salt 公開 → 雜湊**不能**真正隱藏序號可枚舉回推它擋的是「順手抓完整序號」的被動洩露非「防定向還原」。security 判定與威脅相稱(序號非 credential、控制裝置仍需 token+OIDC不需過度設計。
- **`agentVersion` 已移除**security review 議題 1完整 build 版本 = 給攻擊者精準 CVE 對照A06。前端只需判斷「支不支援 local upload」→ `supportsLocalUpload: true` 布林已足夠。
- **最小揭露**hello **不回**任何其他欄位deviceId 明文 / 裝置型號 / driver 版本 / 機器名一律不回)。
- CORS需回完整 ACA* + PNA header`Access-Control-Allow-Private-Network: true`Host 驗證 = loopback§6.4)。
#### POST `/api/local/issue-token` — 產 one-time upload token
- 取得路徑:僅經既有 tunnel 由 api-server 轉發呼叫api-server 已驗 OIDC session + 裝置歸屬,見下方 `/api/devices/:serial/local-upload-ticket`)。**不對瀏覽器 CORS 白名單開放**(非 `/api/local/media/upload/*` 直連 route受 §6.4 Host 驗證約束。
- 註C1 一致性):不再用「無 Origin 即放行」作判準(該反模式已於 ADR-019 §2.4.1 刪除。token 只是縱深防禦一層——同機惡意程序的攻擊面由 C1 route 分離 + Host 驗證處理issue-token 產出的 token 仍綁 deviceId + one-time + 120s TTL即使被同機程序取得也僅能對已歸屬裝置發一次短期上傳。
- Request`{ "serial": "string" }`
- Response 200`{ "success": true, "data": { "token": "string", "expiresAt": <unix_ms>, "ttlSeconds": 120 } }`
- Response 429token 上限32 個未使用)已滿 → `{ "error": { "code": "LOCAL_TOKEN_LIMIT" } }`
- Token 設計見 ADR-019 §2.4,核心設計已經 security agent 審通過review 議題 2
#### POST `/api/devices/:serial/local-upload-ticket` — 雲端 ticket新增雲端 endpoint
- 位置:**api-server雲端**,非 local-agent
- 驗 OIDC session + 裝置歸屬 → 經既有 tunnel 打 local-agent 的 `/api/local/issue-token` → 回傳 token 給瀏覽器
- 這是 token 的取得路徑,走既有已認證的 tunnel控制面
### 6.4 CORS / Host 驗證 / PNA 要求local-agent見 ADR-019 §2.5
- Origin 白名單來源env `VISIONA_CLOUD_ORIGINS`(逗號分隔完整 originstage 預設含雙入口 `https://stage-9527.innovedus.com:9527` + `http://192.168.0.130:9527`(純 HTTP 內網入口)+ dev `http://localhost:3000`
- loopback 舊白名單(`middleware.go:17-22`)保留不動
- **[M3 — 必做] 雲端 origin 採「完整 origin 精確比對」**scheme+host+port **全等**逐字比對),**不可**沿用既有 `isAllowedOrigin` 的 hostname-only + 任意 port + 放寬 scheme 邏輯。白名單存完整 origin 字串、整串相等才通過(否則會變成「該網域任意 port / 任意 scheme 都放行」)。
- Response雲端 origin 通過):**`Access-Control-Allow-Credentials: false`M3 必做,不可對雲端 origin 回 true**、`Access-Control-Allow-Headers: Content-Type, X-Visiona-Local-Token``Access-Control-Max-Age: 600``Vary: Origin`
- **[M2 — 必做] Host header 驗證 = loopback**:對所有 `/api/local/*`(含 `/api/local/media/upload/*`)與舊 tunnel-path media route`Host`(去 port必須 ∈ `{127.0.0.1, localhost, ::1}`,否則 `400 Bad Request`DNS rebinding 獨立第二道防護tunnel 轉發的 Host 本就是 `127.0.0.1:<port>` 故不受影響)。
- **PNA必做**preflight 帶 `Access-Control-Request-Private-Network: true` 且通過白名單 → 回 `Access-Control-Allow-Private-Network: true`
### 6.5 錯誤碼(跨模組統一)
| 錯誤碼 | HTTP | 情境 | 誰產生 |
|--------|------|------|--------|
| `LOCAL_AGENT_NOT_FOUND` | —(前端內部狀態) | 掃描無回應(非同機 / agent 沒跑) | frontend |
| `LOCAL_AGENT_MISMATCH` | —(前端內部狀態) | 掃描有回應但 serial 不符 | frontend |
| `LOCAL_TOKEN_INVALID` | 401 | token 不存在 / 過期 / 已使用 / 缺失(直連 route 一律要 token | local-agent |
| `LOCAL_TOKEN_LIMIT` | 429 | 未使用 token 達 32 上限 | local-agent |
| `LOCAL_UPLOAD_TOO_LARGE` | 413 | 上傳超過 size 上限video ≤500MB / batch 合計 ≤80MBM1 | local-agent |
| Host 驗證失敗) | 400 | `Host` header 非 loopbackDNS rebinding 防護M2 | local-agent |
---

View File

@ -312,6 +312,32 @@ POC 已完整實作此流程visionA 直接沿用。
- S3 presigned URL 讓瀏覽器直連scalable
- 需要時 api-server 再叫 local agent「下載這個 model 的 URL」由 local agent 用自己網路下載
### 5.3.1 media 上傳(影片 / 圖片 / 批次,**同機 localhost 直連、不走 tunnel**,見 ADR-019
> **此節修補 §5.3 的邏輯漏洞**§5.3 建立了「大檔不走 tunnel因為走 tunnel = 占用使用者本地頻寬兩次」的原則,但此原則過去**只套用到模型上傳,未套用到 media**。影片同樣是大檔(可達 500MB卻仍走 tunnel頻寬雙倍 + 撞 nginx 100M + 300s timeout + stage 落地。ADR-019 把同一原則延伸到 media。
```
[瀏覽器] 進影片分頁 → 掃描 127.0.0.1:3721..3740 找 local agent同機偵測 + serial 身分驗證)
↓ 經既有 tunnel 向雲端要 one-time upload token控制面走 tunnel
[api-server] POST /api/devices/:serial/local-upload-ticket → 驗 OIDC + 歸屬 → 經 tunnel 打 local agent issue-token
↓ token 回瀏覽器
[瀏覽器] 直接 POST 影片到 http://127.0.0.1:<port>/api/local/media/upload/video不走 tunnel
Header: X-Visiona-Local-Token新的、一律要 token 的直連 route見 ADR-019 §2.4.1 C1 route 分離)
[local HTTP server] 驗 token + 驗 Host=loopback → 內部轉呼叫既有 UploadVideo handler零改動→ 啟動推論 pipeline
↓ MJPEG 結果串流 / 推論 WS 結果 → 仍走雲端 tunnel結果面走 tunnel
[瀏覽器] 從雲端 tunnel 收到推論結果
```
**混合路徑(關鍵)**:只有「上行大檔」走 localhost控制面token與結果面MJPEG / 推論 WS仍走雲端 tunnel。這是繼「§5.1 REST via tunnel」「§5.2 WS via tunnel」「§5.3 模型上傳 presigned 直連」之後的**第四種資料路徑****同機 localhost 直連**。
**為何走 localhost 而非 tunnel延續 §5.3 原則)?**
- 500MB 影片經 tunnel = 上傳 500MB + 結果下行 ≈ 1000MB兩次都佔使用者本地頻寬改 loopback 後上傳成本趨近零0.12.5s
- 繞過 nginx `client_max_body_size 100M`、api-server 300s timeout、stage 磁碟落地
- local agent 端 media route 已存在、handler 與 tunnel 解耦,瀏覽器直連零改動
**限制(產品定位)**:需在執行 visionA Agent 的同一台電腦操作(非同機時該分頁停用 + 提示);僅支援 Chromium 系Chrome/Edge。Camera即時攝影機不受此限、仍支援遠端檢視——只有上行大檔需同機。認證與隱私設計細節見 ADR-019 §2.4 / §5token / serial 隱私已經 security agent 審並依 C1/M1/M2/M3/議題1 修正契約)。
### 5.4 轉檔呼叫Phase 1
```