實作 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>
98 lines
3.9 KiB
Go
98 lines
3.9 KiB
Go
package api
|
||
|
||
import (
|
||
"errors"
|
||
"log"
|
||
"net/http"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// ADR-019 §2.4.1 + §4.3.1:本機直連 upload route 的 token 驗證 + size 上限中介。
|
||
//
|
||
// size 上限(M1,各 route 自己的值)。video 硬牆 ≤ 500MB(前端正常上限 90MB,
|
||
// 但 server 端硬牆設 500MB 作為 DoS 上界——攻擊面不因走 loopback 而縮小)。
|
||
// batch 合計 ≤ 80MB。image 沿用 batch 上界即可(單檔遠小於此)。
|
||
const (
|
||
maxVideoUploadBytes = 500 * 1024 * 1024 // 500MB
|
||
maxBatchUploadBytes = 80 * 1024 * 1024 // 80MB(合計)
|
||
maxImageUploadBytes = 80 * 1024 * 1024 // 80MB(單檔,寬鬆上界)
|
||
)
|
||
|
||
// tokenConsumer 抽象 TokenStore.Consume,方便測試注入。
|
||
type tokenConsumer interface {
|
||
Consume(token, deviceID string) error
|
||
}
|
||
|
||
// LocalUploadGuard 是本機直連 upload route 的中介,順序如下(安全關鍵):
|
||
//
|
||
// 1. 先要求 X-Visiona-Local-Token header——缺失即 401(一律要 token、不看 Origin,C1)。
|
||
// 2. 用 http.MaxBytesReader 把 request body 包上 maxBytes 硬牆——
|
||
// 在讀取 multipart body 之前就限制總位元組,避免「未驗證就先收無上限大檔」(M1)。
|
||
// 3. 解析出 deviceId(PostForm 觸發 multipart 解析,但已被 MaxBytesReader 上限保護)。
|
||
// 若超過上限 → ParseMultipartForm 回 *http.MaxBytesError → 413 LOCAL_UPLOAD_TOO_LARGE。
|
||
// 4. Consume(token, deviceId)——single-flight 持鎖(查存在+比對+刪除同一 Lock,防 race,m2);
|
||
// deviceId 綁定不符 / 過期 / 已用 / 不存在 → 401 LOCAL_TOKEN_INVALID。
|
||
// 5. 通過 → c.Next() 進既有 handler(handler 業務邏輯零改動、直接 FormFile 讀已快取的表單)。
|
||
//
|
||
// 稽核 log:consume 成功/失敗記 deviceId + 時間,絕不 log token 明文。
|
||
//
|
||
// 為什麼 deviceId 取自表單而非 token:token 在 issue 時已綁 deviceId,Consume 會用
|
||
// ConstantTimeCompare 驗證「表單 deviceId == token 綁定 deviceId」,兩者不符即 401。
|
||
// 表單 deviceId 是既有 handler 本來就讀的欄位(api-spec §6.2 body 格式不變)。
|
||
func LocalUploadGuard(store tokenConsumer, maxBytes int64) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
token := c.GetHeader("X-Visiona-Local-Token")
|
||
if token == "" {
|
||
respondTokenInvalid(c)
|
||
return
|
||
}
|
||
|
||
// M1:body 硬牆。放在解析 multipart 之前。
|
||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
|
||
|
||
// 觸發 multipart 解析取 deviceId。body 已被 MaxBytesReader 上限保護。
|
||
// 解析錯誤要區分「超過 size 上限(413)」與「其他 400」。
|
||
if err := c.Request.ParseMultipartForm(32 << 20); err != nil {
|
||
var maxErr *http.MaxBytesError
|
||
if errors.As(err, &maxErr) {
|
||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"success": false, "error": gin.H{
|
||
"code": "LOCAL_UPLOAD_TOO_LARGE", "message": "upload exceeds size limit",
|
||
}})
|
||
c.Abort()
|
||
return
|
||
}
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": gin.H{
|
||
"code": "BAD_REQUEST", "message": "invalid multipart form",
|
||
}})
|
||
c.Abort()
|
||
return
|
||
}
|
||
|
||
deviceID := c.Request.FormValue("deviceId")
|
||
|
||
if err := store.Consume(token, deviceID); err != nil {
|
||
// 稽核:consume 失敗(deviceId + 時間,不含 token)。
|
||
log.Printf("[local-token] consume REJECTED deviceId=%s ts=%s",
|
||
deviceID, time.Now().UTC().Format(time.RFC3339))
|
||
respondTokenInvalid(c)
|
||
return
|
||
}
|
||
|
||
// 稽核:consume 成功。
|
||
log.Printf("[local-token] consume OK deviceId=%s ts=%s",
|
||
deviceID, time.Now().UTC().Format(time.RFC3339))
|
||
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
// respondTokenInvalid 統一回 401 LOCAL_TOKEN_INVALID 並中止。
|
||
func respondTokenInvalid(c *gin.Context) {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": gin.H{
|
||
"code": "LOCAL_TOKEN_INVALID", "message": "missing or invalid upload token",
|
||
}})
|
||
c.Abort()
|
||
}
|