---
title: C07 api／worker → 檔案儲存
eyebrow: Guidant AI 資安檢視總報告 · 連線清單
h1: C07 api／worker → 檔案儲存（SeaweedFS S3 :8333／MinIO／本機磁碟）
lede: 客戶上傳的每一份檔案——稽核證據、程序書、掃描報告、匯出檔——最後都躺在這條線的另一端。儲存本身不對外、不驗「誰在問」，它只認後端給的一把金鑰；所以「這份檔能不能給你」這個問題，全部要在後端回答完再來碰儲存。掃描命中 **9 條**，幾乎全是後端那一側沒問清楚就去碰儲存。這一頁回答：這條線**怎麼連**、**哪些功能走它**、它帶來**什麼威脅、駭客怎麼打**、我們**要怎麼防、目前做到哪**。
chips:
  - { text: "必有", kind: plain }
  - { text: "金鑰驗身分・不分使用者", kind: warn }
  - { text: "威脅 6 種", kind: accent }
  - { text: "掃描命中 9 條", kind: plain }
---

> **這一頁怎麼來的**：[DFD Level 0](../DFD/dfd-level0.html#c07) 把產品運作時的連線編成 C01～C25；[STRIDE 六頁](../STRIDE/S-spoofing.html)每一條問題都標了發生在哪幾條線。這裡以**連線**為單位整理，命中的 9 條歸成六種攻擊手法。個別問題修了沒不在這頁講，請點條目連回 STRIDE 頁。

## 一、連線圖

```{.mermaid cap="C07 — 後端到檔案儲存。儲存端只認一把 S3 金鑰、不分使用者；「這個人能不能拿這份檔」在 api 的歸屬登記表回答。紅框是這條線上的兩個關鍵出口：預覽（把儲存裡的內容交回瀏覽器執行）與刪除（實體檔一去不回）。"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB'}}}%%
flowchart LR
    U(["👤 已登入使用者"])
    AG(["🤖 代理程式<br/>回報掃描報告"])
    subgraph API["guidant-api"]
        direction TB
        OWN["歸屬登記表<br/>這份檔是誰的？你能看／能刪？"]
        PREV["預覽出口<br/>伺服器決定格式"]
        DEL["刪除<br/>先刪紀錄再刪實體"]
    end
    WK["guidant-worker<br/>分類時取檔"]
    subgraph ST["檔案儲存（三選一，依 STORAGE_CONFIG）"]
        direction TB
        SW[("SeaweedFS S3 :8333<br/>出廠預設・stack 內建")]
        MN[("MinIO／任何 S3<br/>客戶自備")]
        LC[("本機磁碟<br/>/app/static 掛主機目錄")]
    end
    U -- "C02 上傳／下載／預覽／刪除" --> API
    AG -- "C20 掃描報告" --> API
    API == "C07 · S3 金鑰（一把，不分使用者）<br/>物件名＝時間戳_亂數.副檔名" ==> ST
    WK == "C07 · 同一把金鑰" ==> ST
    PREV -- "C01 回程：內容進瀏覽器" --> U
    classDef ext fill:#FBFCFC,stroke:#9AA8AA,stroke-dasharray:3 2
    classDef open fill:#FDECEC,stroke:#C0392B
    class U,AG ext
    class PREV,DEL open
```

## 二、這條線怎麼連

| 項目 | 現況 |
|---|---|
| **誰 → 誰** | `guidant-api`（上傳、下載、預覽、刪除、匯出）與 `guidant-worker`（證據分類時把檔從儲存取出、放進分類工作目錄）→ 檔案儲存後端。**儲存後端不對外**：SeaweedFS 只 `expose: 8333` 給內網，master 狀態頁 :9333 刻意不映射（它沒有認證） |
| **協定／埠** | 依系統設定 `STORAGE_CONFIG.storage_type` 三選一：**SeaweedFS**（出廠預設，stack 內建 `guidant-seaweedfs`，S3 HTTP :8333）、**MinIO 或任何 S3 相容端點**（客戶自備，位址客戶填）、**本機磁碟**（無網路，容器內 `/app/static` 掛主機 `${GUIDANT_DATA_DIR}/static`）。S3 兩種走同一個 SDK、同一支 adapter（套件 `jedi_file_upload/infra/adapter/minio/`），差別只在型別代號。**HTTP 明文**（`secure=false`；總表 #124 裁定接受） |
| **傳什麼** | 使用者上傳的檔案本體（證據、程序書、附件、匯入來源檔）、代理程式回報的掃描報告、預覽時轉出的 PDF（衍生檔）、匯出檔。每個檔在 `upload_files` 表記一筆（原檔名、副檔名、儲存型別、物件名、歸屬範圍 `storage_scope`） |
| **怎麼驗身分** | **一把 S3 存取金鑰，所有行程、所有使用者共用**（SeaweedFS 的 `s3.json` 由 installer 產生後唯讀掛入；MinIO 的金鑰存在 `system_configs`）。儲存端不知道「哪個使用者在要檔」——**「這份檔能不能給你」完全由 api 的歸屬登記表（`core/plugins/file_upload.py` 的 `*FileOwnershipProvider`）回答**，儲存只負責存取。本機磁碟模式沒有金鑰，靠主機目錄權限 |
| **物件怎麼命名** | 伺服器自己產：`<時間戳>_<亂數 uuid>.<副檔名>`（套件 `common/utils/file_utils.py` 的 `generate_save_info()`）。**上傳者的檔名不進儲存路徑**，只有副檔名照抄；原檔名另存資料庫供顯示 |
| **大小上限** | 全站請求上限 50MB（`MAX_REQUEST_BODY_MB`，`core/app_factory.py` 第 113 行，werkzeug 讀 body 階段就擋 413）。套件本身沒有另設單檔上限；前門 nginx 不限（[C01](C01-browser-to-frontdoor.html) D7） |
| **沒有 `-s3.config` 就是匿名全開** | SeaweedFS 的 S3 gateway 不帶憑證檔參數就**任何連得到 :8333 的人都能讀寫全部檔案**。compose 強制帶 `-s3.config`，憑證檔掛的是「檔」不是目錄——主機端檔案不存在時 docker 會建成目錄，容器讀不到憑證會退回匿名或起不來 |
| **何時存在** | 必有。BE 三個行程 `depends_on` SeaweedFS healthy 才起 |

依據：`docker/production/docker-compose.yml`（`guidant-seaweedfs` 段、`x-guidant-common` 的 `volumes` 與 `depends_on`）、`app/upload_file/service/managed_file_upload_service.py` 的 `_build_config_dto()`、`core/plugins/file_upload.py`、`core/app_factory.py` 第 96～113 行、套件 `jedi_file_upload/common/utils/file_utils.py`、`infra/adapter/minio/minio_adapter.py`、`common/utils/preview_policy.py`、DFD Level 0 §2.2／§2.7。

## 三、哪些功能會走這條線

| 群 | 功能 | 對威脅的意義 |
|---|---|---|
| **① 上傳** | 稽核證據、SSP 程序書、意見回饋附件、框架 PDF、檢測規則包、任務 Excel 匯入來源檔；代理程式回報的掃描報告（經 [C20](../DFD/dfd-level0.html#c20)） | 進來的東西**全部不可信**：內容、檔名、宣稱的格式都是上傳者給的。上傳這一步決定了「儲存裡躺的是什麼」 |
| **② 下載／預覽** | 證據清單點開、程序書下載、報告預覽、匯出檔取回 | 這是**兩個方向的風險交會點**：往外是「給錯人」（歸屬沒問清楚），往內是「給對人但內容是毒」（別人上傳的網頁檔在受害者瀏覽器執行，見 [C01](C01-browser-to-frontdoor.html) T4） |
| **③ 刪除** | 刪證據、刪程序書、刪附件、刪範本掛的文件 | 實體檔一刪就回不來。要問三件事：你能刪嗎、你刪的那份是不是你指的那份、紀錄刪成了實體才能刪 |
| **④ 背景取檔** | worker 分類證據時從儲存取檔、放進分類工作目錄給沙箱（[C08](C08-worker-to-sandbox.html)） | worker 用同一把金鑰，以建單者身分跑（[C09](C09-api-to-worker.html)）；取檔這步有沒有再問歸屬，取決於 C09 的身分傳遞 |
| **⑤ 系統共用資產** | 原廠給所有客戶的公版指引、公版規則包（`storage_scope='system'`） | 全租戶共讀、資料庫隔離對它放行——所以**刪除**這一側要另外擋，不然任何客戶的一個帳號就能刪掉所有客戶的公版 |

## 四、這條線會帶來什麼威脅、駭客怎麼打

掃描在這條線上抓到 9 條，歸納成**六種攻擊手法**。前提都是**攻擊者有一個有效帳號**（任何客戶的任何員工），T1 的部分條目連帳號都不用（控制得了代理程式回報的人）。每種寫駭客實際的步驟與得手什麼，條目是實例。

| # | 風險 | 威脅 | 駭客怎麼打 | 得手什麼 | STRIDE | 實例 |
|---|---|---|---|---|---|---|
| T1 | 🟠 高 | **上傳一份帶程式的網頁檔，等別人預覽時在他的瀏覽器裡執行** | ① 做一個內含程式的網頁檔，或把它改名成 `.png`；② 當證據上傳，或讓代理程式用這個檔名回報掃描報告；③ 檔案進儲存，一切正常；④ 稽核人員在證據清單點「預覽」；⑤ 伺服器照上傳者宣稱的副檔名把它當網頁送回，瀏覽器在本站網域執行那段程式，用受害者的身分讀改資料 | 受害者（通常是稽核人員）的登入身分 | [E 權限提升](../STRIDE/E-elevation-of-privilege.html)、[S 冒充身分](../STRIDE/S-spoofing.html) | [M02-4 上傳的網頁檔，別人點預覽時被當成程式執行](../STRIDE/E-elevation-of-privilege.html#m02-4) 🟠<br>[M03-13 掃描報告「檔名叫什麼就存什麼」，走同一條預覽路徑](../STRIDE/E-elevation-of-privilege.html#m03-13) 🟠 |
| T2 | 🟠 高 | **拿到檔案編號就下載，不管那份檔是誰的** | ① 用自己的帳號在看得到的頁面記下檔案編號長什麼樣；② 從別的清單（例如範本掛的程序書清單，任何登入帳號都列得出來）拿到別人檔案的編號，或直接猜；③ 打下載網址，端點只驗「有登入」，儲存只認 api 的金鑰，檔案就下來了 | 別部門、別專案的稽核證據；範本附掛的程序書全文 | [I 資料外洩](../STRIDE/I-information-disclosure.html) | [M02-1 拿到檔案編號就能下載別人的檔案](../STRIDE/I-information-disclosure.html#m02-1) 🟠<br>[M11-16 範本掛了哪些程序書任何人都列得出來，附檔案編號就能下載](../STRIDE/I-information-disclosure.html#m11-16) 🟡 |
| T3 | 🟡 中 | **檢查的是 A、刪的實體檔是 B** | ① 自己開一個專案，成為管理者；② 刪除請求的網址填自己的計畫，但文件編號填別人專案（甚至別家公司）的佐證文件；③ 系統確認「你管自己的計畫」就放行，去儲存把那份實體檔刪掉；④ 或者批次刪附件時系統算出了「哪些檔真的屬於這張」的過濾結果，刪實體檔卻用回沒過濾的清單 | 刪掉別人專案、別家公司的佐證文件，實體檔一起銷毀、不留痕 | [T 竄改資料](../STRIDE/T-tampering.html) | [M11-11 刪佐證文件時檢查一份、刪的是另一份](../STRIDE/T-tampering.html#m11-11) 🟡<br>[M23-7 刪附件時算出了過濾結果卻沒拿來用](../STRIDE/T-tampering.html#m23-7) ⚪ |
| T4 | 🟡 中 | **先刪實體、再刪紀錄——紀錄被擋下但實體已經沒了** | ① 任何一家客戶的一般員工，在畫面上看到一個原廠公版檔的編號；② 按刪除；③ 系統先去儲存刪實體檔；④ 接著刪資料庫紀錄時被租戶隔離擋下（那不是你的），但程式不看結果、回「刪除成功」；⑤ 紀錄還在、檔沒了，所有客戶打開這份公版都是壞檔 | 一個帳號讓原廠給所有客戶的共用範本全部打不開 | [D 讓服務停擺](../STRIDE/D-denial-of-service.html) | [M02-12 刪原廠共用檔時實體先被刪、紀錄卻刪不掉](../STRIDE/D-denial-of-service.html#m02-12) 🟡 |
| T5 | ⚪ 低 | **刪了主檔，預覽時轉出的 PDF 還留在儲存裡** | ① 使用者刪掉一份敏感檔；② 本機儲存模式只刪主檔；③ 先前預覽時轉出的 PDF 副本還在硬碟上；④ 能讀主機目錄或備份的人照樣拿到內容 | 使用者以為已刪除的檔案內容 | [I 資料外洩](../STRIDE/I-information-disclosure.html) | [M02-9 本機儲存刪檔時漏清轉檔產生的 PDF](../STRIDE/I-information-disclosure.html#m02-9) ⚪ |
| T6 | ⚪ 低 | **在容器網路上側錄後端與儲存之間的明文流量** | ① 攻擊者先進到客戶那台主機；② 在容器網路上側錄 :8333 的流量；③ 讀到檔案內容與 S3 金鑰；④ 但到了這一步，直接讀主機上的設定檔與儲存資料夾更快 | 客戶上傳的檔案內容與儲存金鑰（前提是已進到主機） | [I 資料外洩](../STRIDE/I-information-disclosure.html) | [M02-11 連自己裝的檔案儲存空間時不加密](../STRIDE/I-information-disclosure.html#m02-11) ⚪ |

**六種手法的共同點**：T2～T5 全是同一件事——**儲存不分使用者、只認 api 的金鑰，所以「能不能」「刪哪份」「先刪誰」每一個問題都要在 api 回答完再碰儲存；api 漏問一個，儲存就照做**。T1 是另一件事——**儲存裡躺的是別人給的東西，交回瀏覽器之前要由伺服器重新決定它是什麼**。T6 是這條線的物理特性，邊界在主機外圍。

## 五、我們要怎麼防、目前做到哪

對應六種威脅與這條線的本質，防線分八條。D1～D5 直接對應掃描抓到的手法；**標 ◇ 的（D6～D8）是依這條線的特性補的標準防線，掃描範圍沒涵蓋、沒有實例**。「目前」欄寫程式與部署裡**實際有的機制**；⚠️ 表示目前只靠慣例或只守到局部，沒有機制保證。

| # | 防線 | 擋哪種威脅 | 目前做到哪 |
|---|---|---|---|
| D1 | **預覽出口由伺服器決定格式，不信上傳者說的**：可在瀏覽器裡開的格式白名單；伺服器讀檔頭驗真實格式；網頁類帶 CSP sandbox；所有檔案回應帶 `nosniff`；外來檔名進儲存前先消毒 | T1 | ✅ 預覽路徑全部做到（套件 `preview_policy.py`：白名單 pdf／html／png／jpg／gif／webp，檔頭特徵碼比對，不用主機的 mimetypes 表）；掃描報告檔名先過 `sanitize_report_filename()`。⚠️ 下載（非預覽）那條回 `octet-stream` 強制下載，不受影響；**上傳時不驗格式**，毒檔照樣進儲存，只是出口擋住——新增任何「不經 `preview_policy` 的出口」就會再開一次 |
| D2 | **檔案歸屬登記表，每份檔要有人認領**：各業務模組登記「這檔是不是我的、這人能不能碰」；沒人認領的孤兒檔**一律拒絕**；拒絕回「找不到」不回「無權限」；下載／預覽問「能看」、刪除問「能刪」，分開問 | T2、T3 | ✅ 套件 `IFileOwnershipProvider` 介面＋主專案四份登記（稽核證據、SSP 程序書、意見回饋附件、系統共用資產）＋套件可擴充（`build_extra_ownership_providers`）；下載路由與底層取檔各掛一道（CM-2033）；範本程序書清單補看範本權限（CM-2186）。⚠️ **平台管理員例外口**（`_platform_admin_escape()`）對歷史孤兒檔一律放行，範圍到 root 租戶管理員；**新模組的檔若忘了登記，會變成孤兒被擋**（fail-closed，但症狀是「檔案不見了」） |
| D3 | **刪除時兩個編號必核對歸屬；刪實體檔用過濾後的清單** | T3 | ✅ 兩條逐支修（`_ref_doc_belongs_to_ssp`、`delete_from_pool` 單一查詢、`delete_attachment` 改用 `matched_file_uids`）。⚠️ 逐支修，沒有框架層強制（同 [C02](C02-frontdoor-to-api.html) D2） |
| D4 | **先刪紀錄、紀錄真的刪掉才刪實體**；原廠共用檔對寫入一律拒絕 | T4 | ✅ 套件 `record_first_delete.py`（CM-2228）：先刪紀錄、看結果、只對真的刪掉的那幾筆動實體；`SystemAssetFileOwnershipProvider` 對寫入回「找不到」，只有平台管理員走例外口（CM-2033） |
| D5 | **衍生檔跟主檔同生共死**：預覽轉出的 PDF 在刪主檔時一起清，物件儲存與本機儲存同一套 | T5 | ✅ 套件 `derived_files.py` 的 `delete_derived_files` 兩種模式共用（CM-2062）。⚠️ 刪衍生檔失敗只記日誌不擋主檔，屬刻意寬容 |
| D6 ◇ | **儲存端點不對外、憑證不缺、物件名由伺服器產** | T6、T2 的基礎 | ✅ SeaweedFS 只 `expose` 不 `ports`、:9333 不映射、`-s3.config` 強制；物件名 `時間戳_uuid.副檔名`，上傳者檔名不進路徑。⚠️ **不加密**（裁定接受，邊界在主機）；上傳時 S3 的 `content_type` 照上傳者宣稱的 mimetype 存（儲存不對外所以目前無差，若日後開 presigned URL 直連就有差）；DFD §3 點名 **STG 的 `STORAGE_CONFIG` 指向 188 上一組對外開 8333 的舊 SeaweedFS**，不是 stack 內那台——出貨版 installer 預設指哪台要確認 |
| D7 ◇ | **上傳大小有上限、解析前先量**：單檔上限、解開後大小、檔案數 | T1 的延伸（特製檔讓解析卡死，見 [C02](C02-frontdoor-to-api.html) T10） | ✅ 全站 50MB（`MAX_REQUEST_BODY_MB`）在 werkzeug 讀 body 階段就擋。⚠️ 套件沒有單檔上限，各業務模組各自設（檢測源碼包 100MB、規則包 50MB…），通用上傳走全站上限；前門不限 |
| D8 ◇ | **切換儲存後端不丟舊檔**：每個檔記自己躺在哪種後端，讀取時借對應設定 | 維運面 | ✅ `upload_files.storage_type` 記每檔後端，讀取時據此借設定。⚠️ **切換後端不搬舊檔**（DFD §2.7 已知缺口）——舊後端的憑證要一直留著，否則舊檔全部讀不到 |

**最該補的一步**：D2 的**覆蓋率清單**。歸屬登記表是 fail-closed 的好設計，但「哪些模組的檔已登記、哪些還在走平台管理員例外口」沒有清單；歷史孤兒檔有多少、它們是什麼，也沒盤過。例外口開著的那天，平台管理員帳號一被拿到就是全部檔案。

## 六、依這些防線，掃描還沒看過的地方

- **歸屬登記表覆蓋率與孤兒檔盤點**（D2）：全庫 `upload_files` 有多少筆是四份登記都認不出來的、它們屬於哪些功能，決定例外口能不能收窄。
- **上傳時的格式驗證**（D1）：目前只在出口擋。上傳時就驗檔頭、拒絕「副檔名與內容不符」的檔，能把毒檔擋在儲存外面，也讓不經 `preview_policy` 的新出口少一層風險。
- **本機磁碟模式的主機目錄權限**（D6）：`/app/static` 掛主機 `${GUIDANT_DATA_DIR}/static`，主機上哪些帳號讀得到這個目錄、備份流程會不會把它帶出去，掃描是程式碼範圍、沒看部署。
- **worker 取檔時的歸屬**（④ 背景取檔）：worker 以建單者身分跑，取檔那步有沒有再過 D2 的登記表、還是直接用物件名取，沒逐段追。

---

*依據：STRIDE 六頁信任邊界連線標記（CM-2403／2404 驗收後版本）、`docker/production/docker-compose.yml`、`app/upload_file/service/managed_file_upload_service.py`、`core/plugins/file_upload.py`、`core/app_factory.py`、套件 jedi-file-upload（`file_utils.py`、`minio_adapter.py`、`preview_policy.py`、`record_first_delete.py`、`derived_files.py`）、DFD Level 0 §2.2／§2.7／§3。分組依「駭客怎麼打」歸納，同一條若兩種手法都沾，歸主要那種。*
