# Security Notes — Phase 1 + Phase 0.8b > 本文件記錄 Phase 1 + 0.8b 已知的安全設計決策、被接受的風險、以及對應的 mitigation 與 Phase 2 改進候補方案。 > > **更新時機**:每次安全審查(Reviewer / Security Auditor)發現新風險或變更現有 trust assumption 時,必須更新此檔案。 > > **Phase 0.8b 主要變動**:visionA → converter 對外 auth 從 OAuth JWT 改 pre-shared API key(ADR-010)。Trust boundary 模型不變,但 auth mechanism 大幅簡化。詳見下方「Auth Security」與「API Key Management」章節。 ## 索引 | Section | 內容 | |---------|------| | [Trust Boundary](#trust-boundary重要-design-risk) | user_id 來源信任問題(Phase 1 接受風險,Phase 0.8b 風險模型不變) | | [Input Validation](#input-validation) | 已落實的輸入驗證機制 | | [Storage Security](#storage-security) | MinIO object key 控制與 cleanup 策略 | | [Auth Security](#auth-security) | **Phase 0.8b 改寫**:API key + (保留) OAuth client for promote | | [API Key Management](#api-key-management) | **Phase 0.8b 新增**:CONVERTER_API_KEY rotation / 部署 / 外洩處理 | | [Rate Limiting](#rate-limiting) | 雙層 rate limiter 設計 | | [Logging](#logging) | 結構化 log 與敏感資料保護 | | [Phase 2 候補方案清單](#phase-2-候補方案清單) | 已知待補強的設計 | --- ## Trust Boundary(重要 design risk) ### user_id 為 multipart field,受信任 visionA-backend 帶來 #### 設計 `POST /api/v1/jobs` 的 `user_id` 從 multipart form field 傳入,**不是**從 token claim derive。Converter 完全信任 visionA-backend 端把對的 `user_id` 傳進來。 ``` Phase 0.8b 後: visionA-backend Converter │ │ ├── 帶 CONVERTER_API_KEY ──────│ (pre-shared、雙端對齊) │ │ ├── POST /api/v1/jobs ────────→│ Form-Data: │ Authorization: │ user_id: "alice" ← visionA 端決定 │ Bearer │ API_KEY> │ ... │ │ │ │ Converter 端: │ │ - constant-time compare API key(OK) │ │ - 信任 user_id 是「真正提交的 user」 │ │ - 不再驗證 user_id 與 caller 的關係 ``` #### Trust assumption(Phase 1 + 0.8b) visionA-backend 端: 1. **程式碼安全** — 無 XSS / SSRF / RCE 漏洞,user_id 來源可信 2. **infra 安全** — network ACL、IP allow-list、TLS 確保只有 visionA-backend 能呼叫此 API 3. **credential 管理** — `CONVERTER_API_KEY` 不外洩、不放 git、不寫 log(**Phase 0.8b 改**:原為 `client_secret`) 4. **audit log 健全** — visionA 端能追溯「哪個真實用戶觸發了哪次轉檔」 #### Risk(被接受) visionA-backend **一旦被 compromise**,attacker 可用同一個合法 `CONVERTER_API_KEY`(Phase 0.8b 後;Phase 1 為 OAuth `client_credentials`): | 攻擊面 | 影響 | |-------|------| | 為任意 `user_id` 建 job | 冒充任何 user(user_id 完全由 attacker 控制)| | 鎖定特定 user 7 天 | active_job conflict 機制被當武器(任意 user_id 一旦被鎖,正常請求也 409)| | 偽造的 job 計入 victim user_id 的 history | `user:{victim}:jobs` Set 被汙染,未來查 history 看到不是自己的紀錄 | | 累計 victim 的 job count(如有 quota / billing) | Phase 2 若引入 per-user quota / billing,會誤計到 victim 上 | | 拿任意 NEF(Phase 0.8b 新增 `/result`)| API key 模式下 `/result` 沒 per-job authorization、attacker 可拿任意 jobID 的 NEF | #### Phase 1 / 0.8b 決策(2026-04-25 + 2026-05-16 使用者裁決) **接受此風險。** 理由: 1. visionA-backend 是內部受控系統(非 Internet-facing),compromise 機率低 2. Phase 1 重點是把核心 pipeline 跑通,安全強化排在 Phase 2 3. Phase 0.8b(2026-05-16)改 API key 後,trust model **沒有比 OAuth 更不安全也沒有更安全**: - OAuth 模型:client_credentials 一旦外洩,attacker 也能冒充 visionA 取 token + 建 job - API key 模型:API key 一旦外洩,attacker 直接打 API、相同攻擊面 - 唯一差別:OAuth 有 token expiry(短週期),API key 是長期 secret(更需要 rotation 流程) 4. 不引入 HMAC-signed user_id / OBO: - HMAC 仍是 symmetric secret,被同樣的 compromise 場景突破 - OBO 需要 MC 實作 token exchange、與 ADR-015 「砍跨團隊依賴」精神相反 - 未來真有 multi-caller 需求再回頭加 #### Mitigations(Phase 1 已採用) | Mitigation | 說明 | |-----------|------| | **per-client_id rate limiter(300 req / 5min)** | 限制單一 client(即被 compromise 的 visionA-backend)的攻擊速度 | | **input 完全由 server 控制 object_key** | `jobs/{server-生成 uuidv4}/input/{sanitize 後 filename}`,attacker 無法控制 prefix | | **filename / user_id 嚴格 sanitize** | 阻擋 path traversal / Redis key injection / log injection / XSS / glob pattern | | **structured audit log(含 client_id + user_id pair)** | 可從 log 反查「哪個 client 為哪個 user_id 建了 job」,發現 compromise 時加速 forensics | | **active_job 7 天 TTL**(fail-safe)| 即便 worker 異常未清,TTL 也會自動 GC,避免 attacker 鎖死後永久不釋放 | #### Phase 2 候補方案 ##### 方案 1:HMAC-signed user_id(推薦短期) visionA-backend 用共享 secret HMAC 簽 user_id,Converter 驗簽: ``` visionA-backend Converter hmac = HMAC-SHA256( 收到 multipart 後: secret, recv_user_id, recv_hmac user_id || timestamp) ↓ if HMAC-SHA256(secret, recv_user_id || ts) != recv_hmac: POST /api/v1/jobs ─────────→ return 401 invalid_hmac Form: if abs(now - ts) > 60s: user_id: "alice" return 401 hmac_expired x_user_id_hmac: "" else: x_user_id_ts: "" accept user_id ``` **優點**:實作簡單,雙方只需要共享 secret + 規範 hash。 **缺點**:仍是 symmetric secret,有外洩風險;不會解決「visionA 自己被 compromise」的場景(attacker 也能簽)。 ##### 方案 2:OBO Token / Token Exchange(業界標準,推薦中期) visionA-backend 為每個 user 取 user-context token(例如 OBO flow / Token Exchange RFC 8693),Converter 從 JWT claims 取 `user_id`: ``` visionA-backend Member Center Converter POST /token ──────────────→ grant_type=token-exchange subject_token= audience=converter subject_token_type=jwt ↓ new token with claims: sub: "alice" ← user 身份 actor: { sub: "visionA-client" } ← 委託 client ←─── new access token ───── POST /api/v1/jobs ─────────────────────────────────────────────────→ Authorization: Bearer ↓ Converter 從 token claims 取 user_id(不是 multipart) ``` **優點**: - 完全消除 trust boundary 問題(user_id 來自 Member Center 簽過的 JWT) - 業界標準,跨 vendor 適用 - 自動 audit chain(actor + subject 雙重身份) **缺點**: - visionA / Member Center 都需要實作 Token Exchange 流程 - 性能:每次 POST /jobs 多一次 Token Exchange round-trip(可 cache 緩解) ##### 方案 3:Audit Anomaly Detection(補強) 偵測同 `client_id` 短期內出現大量不同 `user_id` 的異常 pattern: ``` 監控 metric: unique_user_ids_per_client_per_5min{client_id="visionA-backend-client"} 正常 pattern: - 一個 client_id 5 分鐘內可能服務 5-50 個不同 user_id 異常 pattern: - 一個 client_id 5 分鐘內出現 500 個不同 user_id - 一個 client_id 連續 1 小時內每 5 分鐘出現 < 1 秒的 burst(自動化攻擊) 告警 → 人工介入 → 視情況 revoke client_credentials ``` **優點**:不需要改動 protocol,可獨立實作;對已 deployed 系統最容易加上。 **缺點**:被動防禦(事後發現),無法即時阻擋。 --- ## Input Validation ### 已落實(Phase 1) | 項目 | 實作位置 | 機制 | |------|---------|------| | **filename sanitize** | `src/utils/sanitize.js` `sanitizeFilename` | NUL byte truncation / path.posix.basename / 控制字元 / 白名單字元 / 截長 200 / leading-dot 移除 | | **user_id 嚴格白名單** | `src/utils/sanitize.js` `validateUserId` | `^[A-Za-z0-9._-]+$` regex(Sec M1 強化)+ 額外 `..` 拒絕 | | **version 嚴格白名單** | `src/routes/v1/validators/createJob.js` | `^[A-Za-z0-9._-]+$` regex(Sec M3)| | **model_id 數字範圍** | 同上 | `^\d+$` + 1 ≤ x ≤ 65535 | | **platform enum** | 同上 | `{520, 720, 530, 630, 730}` | | **enable_* boolean** | 同上 | 嚴格 `'true'` / `'false'` 字串 | | **metadata JSON object** | 同上 | JSON.parse + 拒絕 array / null / primitive | | **model 副檔名白名單** | 同上 | `{.onnx, .tflite}`(PRD F-01)| | **model file 大小** | multer + handler | 預設 500MB(multer LIMIT_FILE_SIZE → 413)| | **ref_image per-file 大小** | `validateCreateJobRequest` | 10MB(Sec C2 修正,避免 100 張 × 500MB = 50GB OOM)| | **ref_image 張數** | multer fields config | maxCount=100 | ### 攻擊向量驗證清單 | 攻擊向量 | 防禦點 | 測試 | |---------|-------|------| | Path traversal in filename | `sanitizeFilename` `path.posix.basename` + leading-dot strip | `sanitize.test.js` | | NUL byte truncation | `sanitizeFilename` `split('\0')` | 同上 | | Windows path / backslash | `sanitizeFilename` `replace(/\\/g, '/')` | 同上 | | Redis key injection in user_id | `validateUserId` 拒絕 `:` | 同上 | | XSS in user_id | `validateUserId` 嚴格白名單 | 同上(Sec M1)| | XSS in version | `version` 嚴格白名單 | `createJob.validator.test.js`(Sec M3)| | Unicode RTL override | 嚴格白名單拒絕非 ASCII | 同上 | | Glob / shell metachar in user_id / version | 嚴格白名單拒絕 `*?[];&|$` 等 | 同上 | | ref_image OOM (100 × 500MB) | per-file 10MB 上限 | `createJob.integration.test.js`(Sec C2)| | log injection (CRLF) | 嚴格白名單拒絕 `\r\n` | 同上 | --- ## Storage Security ### MinIO object key 完全 server 控制 ``` inputObjectKey = `jobs/${jobId}/input/${safeFilename}` ^uuidv4 ^server-controlled prefix ^sanitized refImageKey = `jobs/${jobId}/ref_images/${index}_${safeFilename}` ``` attacker 無法控制: - prefix `jobs/` — server hardcode - jobId — server 用 `uuidv4()` 生成 - ref_images index — server 用 `idx` 自增 attacker 部分控制(已 sanitize): - safeFilename — 經 `sanitizeFilename` 處理(最壞情況產生合法的相對檔名) ### M5 方案 A:先寫 MinIO 後 Lua claim 避免「拿到 Lua claim 但 MinIO 失敗」需要 rollback Redis 的複雜度: - MinIO 失敗 → 直接回 502,Redis 完全乾淨 - Lua conflict / throw → cleanup MinIO(fire-and-forget,靠 7d lifecycle 兜底) - enqueue 失敗 → 補償 release Redis + cleanup MinIO(Sec M2 + Reviewer Major-2 修正) ### Sec M4:寫入放大 pre-check handler 在 `writeInputToMinIO` 之前先廉價 GET `user:{userId}:active_job`,若已存在直接回 409。 避免 conflict request 還是上傳完 500MB 才被 Lua reject(節省頻寬與記憶體)。 > ⚠️ pre-check 與 Lua claim 之間仍有 race(兩個 request 同時通過 pre-check),最終 atomicity 仍由 Lua 保證;pre-check 純粹是「optimization」。 ### Sec M5:mount-time STORAGE_BACKEND 檢查 `createJobsRouter` 在 mount 時就檢查 `STORAGE_BACKEND === 'minio'`: - 不對 → **不掛 multer**,POST /api/v1/jobs 直接回 500 misconfiguration - 不會吃 multipart body,避免 misconfig 也消耗 500MB 記憶體 - GET / DELETE / download-tokens 不依賴 storage backend,仍正常掛 --- ## Auth Security ### Phase 0.8b:分兩條軸 | 軸 | 流向 | Auth mechanism | Phase 0.8b 狀態 | |----|------|----------------|---------------| | **對外 API** | visionA → converter | Pre-shared API key(`CONVERTER_API_KEY`)| **新(取代 OAuth)** | | **Promote** | converter → FAA | OAuth client_credentials(既有)| **保留** | ### 1. 對外 API:API key middleware(Phase 0.8b 新) #### 設計 - **Header**:`Authorization: Bearer `(重用 Bearer 格式、client 不需改) - **比對**:`crypto.timingSafeEqual` constant-time compare - **長度**:64 hex chars(`openssl rand -hex 32` 產,128 bits 安全強度、遠超 NIST 推薦的 80 bits) - **req.auth 設定**:通過後 `req.auth.clientId = 'visionA-service'`(固定值、給 rate limiter / log 用) - **失敗行為**:401 `invalid_token` + 主動 `socket.destroy()`(沿用 OAuth M2 行為) - **Fail-fast**:env 未設定 → 503 拒絕所有 request(不 silently allow) #### 為什麼安全 1. **Constant-time compare**:`crypto.timingSafeEqual` 避免 timing attack(即使 attacker 用 differential analysis 也無法推斷 key 內容) 2. **長度檢查在 timingSafeEqual 之前**:避免 throw `RangeError`,但長度本身公開(key 長度為固定值) 3. **64 hex chars** = 256 bits 隨機(`openssl rand -hex 32`),brute force 不可行 4. **不 log key 內容**(任何方向:expected / received 都不 log) #### 失敗情境總表 詳見 `auth.md` §1.4。 ### 2. Promote:OAuth client_credentials(保留) Converter 仍以自己的身分取 `files:upload.write` token、PUT 結果檔到 FAA。 #### 既有設計(不變) - **JWT Algorithm Pin**(既有 Sec m3):在 `auth/oauthClient.js` 解析 MC 回應時不需 verify JWT(取回後直接帶到 FAA);FAA 端負責 verify、與 converter 無關 - **Token cache**:per-scope,distance to expiresAt > refreshSkewMs(預設 60s)算 valid - **In-flight dedup**:同 scope 並發只發一次 token request - **AbortController timeout**:預設 10s - **錯誤分類**:`OAuthClientError`(4xx,不重試)/ `OAuthServerError`(5xx,可重試)/ `OAuthTimeoutError`(網路 / timeout,可重試) #### 401 處理 FAA PUT 回 401 → `oauthClient.invalidate(scope)` + retry 1 次;仍 401 → 503 `auth_service_unavailable`。 ### 3. 砍除(Phase 0.8b) | 移除 | 原因 | |------|------| | `src/auth/middleware.js`(OAuth resource server)| visionA → converter 不再驗 JWT | | `src/auth/jwks.js` | 不需要 JWKS cache | | `MEMBER_CENTER_ISSUER` / `JWKS_URL` env | 不驗 iss / 不取 JWKS | | `KNERON_CONVERTER_AUDIENCE` env | 不驗 aud | | `JWKS_CACHE_MAX_AGE_MS` / `JWKS_COOLDOWN_MS` / `JWT_CLOCK_TOLERANCE_SEC` env | 沒有 JWKS / JWT 了 | | ALLOWED_JWT_ALGS 演算法 pin | 沒有 JWT 驗證了(FAA 端有自己的演算法 pin) | ### 4. 保留(promote 仍需) | 保留 | 用途 | |------|------| | `src/auth/oauthClient.js` | converter → FAA OAuth client | | `MEMBER_CENTER_TOKEN_URL` env | token endpoint | | `KNERON_CONVERTER_CLIENT_ID` / `_CLIENT_SECRET` env | converter 作為 OAuth client 的身分 | | `FILE_ACCESS_AGENT_AUDIENCE` env | FAA 的 audience(取 token 時用)| | `OAUTH_TOKEN_REFRESH_SKEW_MS` / `OAUTH_TOKEN_TIMEOUT_MS` env | token cache 行為 | --- ## API Key Management ### 1. 產生 ```bash openssl rand -hex 32 # 輸出:64 個 hex chars # 範例:a3f9b2c1d8e7f6a5b4c3d2e1f0987654321fedcba9876543210abcdef1234567 ``` ### 2. 部署位置 | 環境 | 位置 | |------|------| | dev | `apps/task-scheduler/.env`(gitignored) | | stage | docker-compose env / k8s secret | | prod | docker secret / k8s secret / cloud secrets manager | ### 3. 雙端對齊 - visionA `.env.stage`:`VISIONA_CONVERTER_API_KEY=` - converter `.env`:`CONVERTER_API_KEY=` - **兩端必須完全相同字串** ### 4. 每環境獨立 dev / stage / prod 各自 `openssl rand -hex 32`,絕不共用。 ### 5. Rotation 流程 1. 雙端各自準備 stop deployment(或允許短暫 401 期) 2. `openssl rand -hex 32` 產新 key 3. 更新雙端 `.env` 4. converter 先 redeploy(接受新 key) 5. visionA 後 redeploy(用新 key call) 6. 驗證:`curl -i -H "Authorization: Bearer " https://converter.../api/v1/jobs?user_id=test&limit=1` **極小停機**做法(Phase 0.8b 不實作):暫時讓 converter 接受新舊兩把 key(middleware 拓展成 array compare)、visionA 切到新 key、再砍舊 key。 ### 6. 外洩處理 - **立即** rotate 雙端 key - 檢視 audit log:在 rotation 前是否有可疑請求(用 `request_id` + `user_id` 追蹤) - 若有 anomalous activity(同 client_id 短期內 100+ 不同 user_id),通報 + forensics - Post-mortem:分析洩漏來源(git history?CI log?slack?)並補強 ### 7. 絕不做的事 - ❌ **絕不**進 git(`.gitignore` 已 exclude `.env`、verify 一次) - ❌ **絕不**寫進 Slack / email / 對話記錄 - ❌ **絕不**印 log(middleware 內 log 用 `api_key_length` 或 `api_key_set: true` boolean、不印 key 本身) - ❌ **絕不**在 commit message / PR description 中引用具體值 ### 8. Sec C1 暫緩(既有風險、Phase 0.8b 仍適用) `.env` 一度被 commit 進 git history。Phase 0.8b 新加的 `CONVERTER_API_KEY` 必須**極度**注意不要重蹈覆轍。 **Phase 1 ready 後**會做一次 git history rewrite + 強制 rotate 所有 secret(含 CONVERTER_API_KEY / 既有的 OAuth client_secret / MinIO secret)。 --- ## Rate Limiting ### 雙層設計 ``` Request → IP-based limiter (200 req / 15min) ← app.js 全域 → requireAuth (驗 token) → per-client_id limiter (300 req / 5min) ← v1 jobs router → multer / handler ``` | 層級 | 目的 | 範圍 | |------|------|------| | IP-based | 防匿名流量 / DDoS | 全 `/api` 前綴 | | per-client_id | 合約上限 / 攻擊速度限制 | `/api/v1/jobs` 寫入端點 | ### Phase 2 待補 - Memory store 警告:目前用 process-local memory,**多 instance 部署需改 Redis store** - 目前對「未認證但合法路由」的 quota 計算可能誤殺 — 預設 1 個 visionA-backend IP 帶多 user 共用,需要監控 IP-based 是否誤殺 --- ## Logging ### 結構化 JSON 所有 log 使用結構化 JSON 格式,必含: - `timestamp`(ISO 8601) - `level`(INFO / WARN / ERROR) - `service`(`task-scheduler`) - `request_id`(貫穿請求生命週期) - `action`(`domain.action` 格式) ### 敏感資料保護 **絕對不寫 log**(已逐條檢查): - token / Authorization header - file body / model 內容 - MinIO secret / OAuth client_secret - JWT payload 完整 dump(只記 `client_id` / `tenant_id` / `user_id`) **遮罩處理**(如有需要在 Phase 2 加): - 原始 filename(已 sanitize)— 通常不視為敏感 - IP(log 仍記,但 GDPR 場景可能需要遮罩) ### Sec C1 暫緩 `.env` 一度被 commit 進 git history(健檢時發現),**已加入 `.gitignore` 但 history 仍可追溯**。 決策: - **Phase 1 暫緩**(2026-04-25 使用者裁決) - **Phase 1 ready 後**會做一次 git history rewrite + 強制 rotate 所有 secret - 在那之前,所有 secret 都被視為「可能已外洩」,**dev 環境用 dummy secret,prod 用全新生成的 secret** --- ## Phase 2 候補方案清單 ### 已知待補強(依優先級) | # | 項目 | 優先級 | 預期任務 | |---|------|-------|---------| | 1 | **OBO token / Token Exchange**(解決 Trust Boundary,**Phase 0.8b 已決策不做 HMAC 中繼方案**)| MEDIUM | Phase 2 — auth 強化(前提:MC 實作 Token Exchange RFC 8693) | | 2 | **Git history rewrite**(清掉 .env 洩漏,**含 Phase 0.8b 新加的 CONVERTER_API_KEY**)| HIGH | Phase 1 ready 收尾 | | 3 | **API key automatic rotation**(整合 secrets manager / Vault)| MEDIUM | Phase 2 — infra | | 4 | **API key 並存模式**(讓 middleware 同時接受新舊兩把 key,支援極小停機 rotation)| LOW | Phase 2 — auth 細修 | | 5 | **MULTIPART_MODEL_MAX_BYTES env 串接** | DONE | Phase 1 T10 | | 6 | **MAX_CONCURRENT_UPLOADS semaphore** | DONE | Phase 1 T10 | | 7 | **Stream storage 評估**(取代 memoryStorage)| MEDIUM | Phase 2 — infra | | 8 | **Rate limiter + bandwidth quota + concurrent stream cap Redis store**(多 instance 部署前提)| **HIGH**(2026-05-17 升級:Phase B 後 `/result` 開放、attacker blast radius 放大、多 instance 部署前必補)| Phase 2 — infra(前提:Phase B 後若有 multi-instance 需求) | | 9 | **Audit anomaly detection**(user_id pattern 異常告警)| LOW | Phase 2 — observability | | 10 | **Filename Unicode normalization**(極端 unicode bypass)| LOW | Phase 2 — security 細修 | | 11 | **Metadata prototype pollution 防護**(白名單 keys)| LOW | Phase 2 — security 細修 | | 12 | **`/result` per-job authorization**(API key 模式下 attacker 可拿任意 jobID)| **MEDIUM**(A.7 follow-up §4 升級)| Phase 2 — auth;考慮 client_id 隔離;前提是 #13 完成(per-caller credential) | | 13 | **加回 OAuth resource server 並存模式 / 多 caller credential**(多 caller 場景)| **MEDIUM**(A.7 follow-up §4 升級)| Phase 2 — 真有第二個 caller 時;#12 的前置工作 | | 14 | **`/result` 404 vs 410 區分的 jobID enumeration risk** | LOW(marginal、待 #12 完成後可一併處理) | Phase 2 — auth 細修;#12 補後可考慮統一為 404 | | 15 | **`/result` per-job auth 啟用時、4xx 統一回 404(不揭露 lifecycle)**(2026-05-17 Security review §1 Q5 新增)| LOW → 隨 #12 同步升級為 #12 的子任務 | Phase 2 — auth;當 #12 完成時、attacker 拿到的 key 不再給 full read access、區分 404/410 才變成真正的 leak、屆時應統一回 404 | | 16 | **MinIO socketTimeout / connectTimeout 對齊 RESULT_STREAM_TIMEOUT_MS**(2026-05-17 Security review §1 Q1 / s2 新增)| LOW | Phase 2 — infra;確認 MinIO SDK timeout 設定、對齊 5 min stream timeout(建議 socketTimeout = STREAM_TIMEOUT - 30s、預留 server-side teardown 時間);避免 MinIO 端 timeout 在 response timeout 前觸發、attacker 看到 stream_error 而非 stream_timeout | --- ## 變更歷史 | 日期 | 變更 | 觸發 | |------|------|------| | 2026-04-25 | 初版 | T5 Reviewer + Security Audit 修復 | | 2026-05-16 | Phase 0.8b 重寫:auth 從 OAuth 改 API key;新增 API Key Management 章節;Trust boundary 風險模型與 Phase 1 一致(只是 secret 形式改變);Phase 2 候補清單更新(OBO 從 HIGH 降 MEDIUM、新增 API key rotation 相關項) | ADR-010 + ADR-011 | | 2026-05-17 | Phase B 啟動前 streaming/range design review:候補 #12 / #13 升 MEDIUM;新增候補 #14(404 vs 410 區分的 jobID enumeration trade-off、當前 trust model 下 marginal risk、文件化保留 TODO-v2 §4.1 規格);`/result` 設計補充章節(rate limit / Range / audit log / source_filename)詳見 `api/api-result.md` §9-§14 | Security Auditor A.7 follow-up §4 + Phase B 啟動前 design review | | 2026-05-17 | Phase B Security design review 第二輪採納(4 Major + 3 Minor):候補 #8 從 MEDIUM 升 **HIGH**(多 instance 部署前必補 Redis store、涵蓋 rate limit + bandwidth quota + concurrent cap 三軸);新增候補 #15(per-job auth 啟用時 4xx 統一回 404、隨 #12 同步)+ 候補 #16(MinIO socketTimeout 對齊 RESULT_STREAM_TIMEOUT_MS);`/result` 設計強化:rate limit 從 60 req/min single tier 改為 two-tier(5 req/10s burst + 20 req/min sustained)+ bandwidth quota(1 GB/hr + 6 GB/24hr);新增 stream response timeout 5 min + concurrent stream cap 10 per-instance;Range header 處理三件事(Accept-Ranges: none + silently ignore + log range_attempted);audit log 從 8 event 擴到 12 event + 強制 A.7 五欄 + /result 特有四欄;filename defense-in-depth(quote-escape + RFC 5987 + buildFilename assertion);Backend acceptance criteria 從 B1-B9 擴充到 AC-1 到 AC-12 + 6 個新 integration test;詳見 `api/api-result.md` §9 / §10 / §11 / §13.4a / §15 / §14 | Security Auditor Phase B streaming/range design review §1 Q1-Q6 + §2 修正 + §3 acceptance criteria |