FR-086.B1 掃描報告:檔案上傳下載的 HTTP 邊界與契約(jedi-file-upload)

  • 卡片CM-1652(母卡 CM-1651
  • 掃描範圍jedi-file-uploadapi/app/ports/common/plugin.py,共 35 檔
  • 版本:commit 8aa6f060(jedi monorepo,branch feature/FR-075
  • 日期:2026-09-11
  • 工具:claude-security plugin,low effort,focus attack-surface
  • 面板:6 條候選 × 3 位檢查員 = 18 票全數投出,stamp verified

1. 🔴 一句話結論

只要有一個能登入的帳號,再知道某個檔案的編號,就能把別家客戶的檔案下載走、線上預覽、還能永久刪掉——系統從頭到尾只檢查「你有沒有登入」,從來沒檢查過「這個檔案是不是你的」。

而且還能替別人的檔案換發一張下載通行證,那張證不需要帳號密碼就能開檔,可以直接把網址傳給公司外面的任何人。

這四條全部是同一個病:認得出你是誰,但不管你能碰什麼。

好消息有一個:卡片列為「本棒最重要」的①路徑穿越(把檔案存到不該存的地方)經查不成立——程式的寫法剛好讓攻擊字串到不了真正寫檔的那一步。詳見第 4 節 B1-5。


2. 這一棒在檢查什麼(白話)

jedi-file-upload 是產品裡所有檔案功能的對外門面。使用者做四件事都走它:

  • 上傳檔案(稽核證據、SSP 文件、問卷附件)
  • 下載檔案
  • 線上預覽檔案(Office 檔會先轉成 PDF,在網頁裡直接開)
  • 跟系統要一張短期下載通行證(因為瀏覽器用 window.open<iframe> 開檔時帶不了登入憑證,所以先換一張「證」放在網址上)

這一棒要回答三個問題:能不能把檔案寫到不該寫的地方、能不能下載到別人的檔案、檔名與大小有沒有把關。

只找問題、不修問題——底下所有修法都只是建議,這一棒沒有動任何一行程式碼,也沒有真的上傳或下載任何檔案去測(全部是讀程式碼推論出來的)。


3. 掃到什麼:總覽表

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 嚴重度 來源
B1-1 任何登入者可以替「任何一個檔案編號」換發一張下載通行證 那張證不用帳號密碼就能開檔,可直接把網址傳給公司外面的人,在有效期內都能開 任何有效帳號 + 知道一個檔案編號 api/routes/upload_file_route.py:156 HIGH 工具 3/3 票
B1-2 上傳的檔案會被原封不動「在網頁裡打開」,副檔名沒有白名單 攻擊者上傳一個網頁檔,別人一點預覽,那段程式就在我們自己的網站網域下執行,可以偷走瀏覽器裡的登入憑證、並冒用受害者身分呼叫 API 能上傳檔案的帳號 + 受害者點開預覽 api/routes/upload_file_route.py:251(送出在 :259-262,遠端那條在 :221-223 HIGH 工具 3/3 票
B1-3 任何登入者可以下載任何檔案,只憑檔案編號 別家客戶的稽核證據、SSP 文件、問卷答案全部可被讀走 任何有效帳號 + 知道一個檔案編號 api/routes/upload_file_route.py:172(送檔在 :185-190 HIGH 工具 3/3 票
B1-4 任何登入者可以刪除任何檔案,刪了救不回來 別家客戶的稽核證據被永久刪除,連帶轉檔快取一起清掉,引用它的資料變成指向空檔 任何有效帳號 + 知道一個檔案編號 api/routes/upload_file_route.py:116 MEDIUM 工具 3/3 票
B1-5 上傳時用使用者填的字串拼存檔路徑(卡片①,列為「本棒最重要」) 經查不成立——攻擊字串到不了寫檔那一步 api/routes/upload_file_route.py:101:134 無問題 工具 0/3 票 + runner 人工複核
B1-6 檔名的副檔名直接取使用者給的字串(卡片④的一部分) 路徑穿越不成立(取不到 ../),但副檔名無白名單這件事是真的,且正是 B1-2 的燃料 common/utils/file_utils.py:16 無問題(穿越)
有問題(無白名單→見 B1-2)
工具 0/3 票 + runner 人工複核
B1-7 完全沒有單檔大小上限、也沒有檔數上限(卡片④的一部分) 一次請求可塞爆磁碟或物件儲存;全域只有 50MB 的「整個請求」上限,不是單檔上限 任何能上傳的帳號 全套件查無任何上限設定 LOW(另記) runner 人工查證
B1-8 套件把守門整個外包給宿主(卡片⑤) 設計是好的——缺守門會拒絕啟動,不是靜默放行 plugin.py:282-300 無問題 runner 人工查證

卡片七個重點的逐項結論在第 5 節,每一項都有「成立/不成立」。


4. 每條發現的詳述

B1-1(HIGH)任何登入者可以替任何檔案換發一張「免登入下載通行證」

這是什麼問題(白話)

瀏覽器用 window.open 或內嵌框架開檔案時,沒辦法帶上登入憑證。所以系統設計了一個折衷:前端先用正常的登入身分去跟後端要一張短期通行證(程式裡叫 st),這張證會綁定「哪一個檔案」,然後把它接在網址後面去開檔。

問題是——發證的那個窗口只檢查「你有沒有登入」,完全不檢查「你要的這個檔案是不是你的」。

你拿自己的帳號,報上別人的檔案編號,它就發一張綁著別人檔案的通行證給你。

出事會怎樣

拿到那張證之後有兩件事特別糟:

  1. 那張證本身不需要帳號密碼——它就是一段網址。可以用 email、通訊軟體傳給公司外面任何人,對方貼進瀏覽器就開得到別家客戶的檔案,在有效期內都算數。
  2. 別家客戶的稽核證據、SSP 文件就是這樣流出去的,而這正是產品存在的目的——保管這些東西。

要先有什麼才打得到

  • 一個任何有效帳號就行(不需要主管、不需要稽核員、不需要是專案成員)
  • 知道一個檔案編號。編號是 UUID 看似難猜,但不需要猜——只要在任何一張列表或詳情頁面看得到附件,API 回應裡就帶著編號

在哪裡

  • jedi_file_upload/api/routes/upload_file_route.py:153 — 只掛了 @auth_required(「有登入就好」)
  • jedi_file_upload/api/routes/upload_file_route.py:156signed_token_issuer(..., resource_uid=uid),直接拿使用者給的 uid 去簽
  • 宿主接線:compliance-manager-be/core/upload_file_wiring.py:133auth_required 接的是純粹的 jwt_required(),只驗身分不驗權限

怎麼修

upload_file_route.pyUploadFileAccessTokenRoute.get() 裡,簽證之前先把檔案撈出來,確認呼叫者有權讀它(是本人上傳的?同一個租戶?是所屬專案的成員?),不符就 raise ForbiddenError

因為套件本身不知道產品的歸屬規則,正解是多開一個必填的 port(例如 adapters.file_access_authorizer(uid) -> bool),跟現有的守門三件放在一起,並在 plugin.py:282_assert_api_wiring 一併檢查——宿主忘了接就開不起來,而不是靜默地把所有檔案變成憑編號即可取用。


B1-2(HIGH)上傳的網頁檔會在我們自己的網域裡被執行

這是什麼問題(白話)

預覽功能的邏輯是:Office 檔先轉成 PDF 再顯示;其他格式就原檔直接丟給瀏覽器顯示as_attachment=False 的意思就是「不要下載,直接在頁面裡開」)。

而「這是什麼類型的檔案」是依使用者上傳時的副檔名決定的,而且副檔名沒有白名單(我整包 grep 過 secure_filename、白名單、MIME 驗證,一個都沒有)。

所以上傳一個 .html.svg,系統就會老老實實地告訴瀏覽器「這是網頁,請執行它」。

出事會怎樣

那段程式是在我們自己的網站網域底下執行的,所以瀏覽器認為它是自己人:

  • 可以讀走瀏覽器裡存的登入憑證(存在 localStorage 的 token)
  • 可以冒用受害者的身分呼叫任何 API
  • 而且後端沒有設任何一道瀏覽器端的防護標頭(沒有 CSP、沒有 nosniff、沒有 X-Frame-Options),所以沒有東西擋得住它

預覽網址正是前端內嵌框架平常在開的那個網址——這條路是產品的正常流程,不是什麼刁鑽路徑。

要先有什麼才打得到

  • 一個能上傳檔案的帳號
  • 受害者點開那個檔案的預覽(分享附件、掛在案件上,對方點一下就中)
  • 副檔名落在「不能轉 PDF」的那一類(html、svg、xhtml 都是)

在哪裡

  • jedi_file_upload/api/routes/upload_file_route.py:251 — 依副檔名猜出 MIME 類型
  • jedi_file_upload/api/routes/upload_file_route.py:259-262send_file(..., as_attachment=False, mimetype=preview_mime),直接在頁面裡開
  • jedi_file_upload/api/routes/upload_file_route.py:221-223 — 遠端儲存那條分支,同款問題
  • 源頭:jedi_file_upload/common/utils/file_utils.py:16 — 副檔名照抄使用者給的檔名

怎麼修

  1. 只有 PDF 與圖片才准在頁面裡開,其餘一律 as_attachment=Trueapplication/octet-stream(變成下載,不是執行)
  2. 檔案回應加上 X-Content-Type-Options: nosniff 與嚴格的 Content-Security-Policy(例如 default-src 'none'; sandbox
  3. 上傳時就擋:加副檔名白名單,不要信任使用者給的檔名

B1-3(HIGH)任何登入者可以下載任何檔案

這是什麼問題(白話)

下載端點掛的守門叫「雙軌」——帶了通行證(st)就走通行證那條,沒帶就退回「只要有登入就好」

退回的那條什麼都不綁:它只確認「你是一個合法使用者」,完全不比對「這個檔案跟你有沒有關係」。

再往下看也沒有兜底:資料層撈檔案時只用 uid 這一個條件,而存檔案的那張表根本沒有租戶欄位、資料庫隔離也是關的(建表腳本自陳),所以資料庫層也救不了。

出事會怎樣

別家客戶的所有上傳檔案都可以被讀走:稽核證據、SSP 文件、問卷答案、合規佐證。

更麻煩的是走通行證那條時,程式會把租戶標成「無」,而系統把「無租戶」當成最高權限、跳過所有隔離——等於連資料庫那層本來就很薄的保護也一起繞過。

要先有什麼才打得到

  • 任何有效帳號
  • 知道一個檔案編號(同 B1-1,API 回應裡到處都是)

在哪裡

  • jedi_file_upload/api/routes/upload_file_route.py:172 — 用 uid 直接取檔
  • jedi_file_upload/api/routes/upload_file_route.py:185-190 — 送出檔案內容
  • 守門實作:jedi-iam/jedi_iam/authz/signed_token.py:155-163 — 沒帶 st 就只跑 verify_jwt_in_request()
  • 資料層:jedi_file_upload/infra/repository/upload_file_repo_impl.py:140 — 只用 uid 過濾(此檔屬 B1b 範圍,此處為追脈絡而讀)

怎麼修

在送檔之前,先把這個檔案的所屬資源找出來(它掛在哪張稽核證據/哪份 SSP/哪題問卷底下),確認呼叫者的租戶與角色有權看它。

治本的做法二擇一:

  1. upload_files 表加租戶欄位並開啟資料庫層隔離(讓資料庫成為最後一道防線)
  2. 或者強制走通行證那條,把「沒帶證就放行」這個退路拿掉——但要注意:這招必須先修好 B1-1,否則通行證本身就是隨便發的,等於沒修

B1-4(MEDIUM)任何登入者可以刪除任何檔案

這是什麼問題(白話)

刪除端點同樣只掛「有登入就好」,拿到編號就刪,沒有任何一層檢查這個檔是不是你的——route 沒查、宿主的 service 也沒查。

為什麼是 MEDIUM 不是 HIGH:影響面比前三條窄一點——它破壞資料但不外洩資料,而且刪除通常會被使用者很快發現(檔案不見了)。但刪掉就救不回來,且刪的是稽核證據。

出事會怎樣

  • 檔案本體被刪掉、資料庫紀錄被刪掉
  • 物件儲存那條還會連帶清掉轉檔產生的 PDF 快取
  • 引用這個檔案的資料(案件、證據、答案)變成指向一個不存在的檔
  • 這些是合規稽核證據,產品存在的目的就是保管它們

要先有什麼才打得到

  • 任何有效帳號
  • 知道一個檔案編號

在哪裡

  • jedi_file_upload/api/routes/upload_file_route.py:113 — 只掛 @auth_required
  • jedi_file_upload/api/routes/upload_file_route.py:116delete_file(uid) 直接送進去
  • 宿主端:compliance-manager-be/app/upload_file/service/managed_file_upload_service.py:360-362:173-178_adapter_for_uid 純粹依檔案自己那列資料決定後端,不驗人)

怎麼修

刪除前先確認所屬資源與角色:把檔案的母體(證據/文件/答案)撈出來,用主專案的 common/authz 檢查呼叫者的租戶與角色(該不該是 manager/auditor)。

退而求其次的最低門檻:只准刪自己上傳的——比對 upload_files.created_user 與呼叫者,不符就擋。


B1-5(無問題)卡片①「用使用者填的字串拼存檔路徑」——經查不成立

這是卡片列為「本棒最重要」的一條,結論是不成立。因為證偽跟證實一樣有價值,這裡寫清楚是什麼擋住了

原本擔心什麼

upload_file_route.py:101 這一行:

save_dir = str(os.path.join(payload.get('upload_type'), payload.get('uid')))

這兩個值來自請求裡的一段 JSON,完全由呼叫端填,而且看不到任何 ../ 過濾。照常理推論,傳 upload_type="../../etc" 就能把檔案寫到系統目錄去(這叫路徑穿越,就是用 ../ 這種寫法讓檔案被存到或讀到原本不該碰的目錄)。:134 批次上傳那條是同款寫法。

為什麼不成立

追下去發現這個被汙染的字串走到一半就死了,寫檔那一步根本讀不到它:

  1. app/service/file_upload_service.py:82 呼叫 set_save_dir(save_dir)
  2. set_save_dir:68)寫的是 self.upload_file_adapter.config.base_dir = save_dir
  3. 但是 .upload_file_adapter 這個屬性(:37-40)會先跑 _ensure_initialized(),那一步當場把 adapter 建出來
  4. LocalFileUploadAdapter.__init__infra/adapter/local/local_file_adapter.py:38)在建構當下就把目錄抄成自己的 self.save_dir
  5. 真正寫檔那行(:57)讀的是 self.save_dir——那份在第 4 步就抄好了,第 2 步的寫入晚了一步、寫進了一個沒人再讀的地方

順序是:先建構(抄走目錄)→ 才寫入(改到沒人讀的欄位)。攻擊字串永遠追不上。

我額外確認過的三件事(因為這條是卡片的重點,不能只信工具的票):

  • 物件儲存那條完全不吃這個值:MinIO/SeaweedFS 的 save_fileinfra/adapter/minio/minio_adapter.py:92)用的是 self.config.bucket_name,跟 base_dir 無關,而且 bucket 名是設定檔來的、不是使用者填的
  • 宿主沒有把它改活:主專案交進去的是 ManagedFileUploadService,它沒有覆寫 set_save_dirupload_files,繼承的就是上面那套死路(覆寫 base_dir 的另外兩支 upload_files_for_tenant / upload_system_file 是背景 job 與系統資產專用,不接使用者請求
  • DI 是 providers.Factorydi_containers/upload_file/upload_file_containers.py:29,每個請求建新的)——我本來擔心「每次都新建 adapter 會不會讓寫入趕上」,但新建反而讓第 3、4 步每次都重跑,順序不變、結論不變

但有一件事要記著:這是靠執行順序意外擋住的,不是有人刻意防的。哪天有人重構掉那個 lazy 建構、或改成先設定再建 adapter,這個洞就會自己長回來set_save_dir 那行值得補一道輸入檢查(只允許安全字元、拒絕 .. 與絕對路徑),把它變成有意的防護


B1-6(部分成立)卡片④「檔名把關很薄」

拆成兩半看,一半不成立、一半成立

不成立的那半:路徑穿越

common/utils/file_utils.py:16

upload_file_extension = file.filename.split('.')[-1]

擔心的是檔名裡塞 ../ 會被拼進存檔路徑。不成立,原因是 split('.')[-1] 只取最後一個點之後的那一段——這一段永遠不可能含有點,所以永遠拼不出 ..。傳 ../../../../tmp/pwned 進去,取出來的是 ./tmp/pwned 的最後一段、不含點的字串;絕對路徑那條也被 :17 的字串拼接寫法結構性擋掉了(副檔名被夾在時間戳與 UUID 中間,跑不到最前面去)。

工具的三位檢查員也是 0/3 票(全判不成立),理由與我開檔看到的一致。

成立的那半:沒有副檔名白名單

確實沒有。我整包 grep 過 secure_filenameallowed_ext、白名單、MIME 驗證——一個都沒有。使用者說這是 .html 它就記 .html

這件事本身不會造成路徑穿越,但它就是 B1-2 的燃料——沒有白名單,才會有「上傳網頁檔然後被當網頁執行」。修法併入 B1-2。


B1-7(LOW,另記)完全沒有單檔大小上限與檔數上限

(工具未報,人工查證)

整包套件查不到任何大小上限或檔數上限的設定。批次上傳端點(:130)用 getlist('file') 收檔,要幾個收幾個

唯一存在的上限是主專案全域的 MAX_CONTENT_LENGTH 50MB(CM-1587 定的),但那擋的是整個請求,不是單檔、也不是檔數。

為什麼只評 LOW:要打到它需要能登入,而且效果是磁碟吃緊/服務變慢,不是資料外洩;50MB 的全域上限也讓單次傷害有天花板。但累積型的塞爆(反覆上傳到把磁碟或物件儲存塞滿)沒有東西擋。

建議:在 plugin.pyFileUploadConfig 加兩個參數(單檔上限、單次檔數上限),在 upload_files 進迴圈前檢查,超過就 raise BadRequestError


B1-8(無問題)卡片⑤「套件把守門整個外包給宿主」——設計是好的

(工具未報,人工查證)

擔心的是:套件自己不做任何判斷,全靠宿主注入守門,那宿主忘了接會怎樣?靜默掛上一個沒防護的端點嗎?

不會。 plugin.py:282-300_assert_api_wiring 會檢查守門三件(auth_required / file_access_guard / signed_token_issuer),缺任何一個就當場 raise RuntimeError 拒絕掛載,服務直接起不來。

程式裡的註解把理由寫得很清楚,大意是:忘記接守門的後果是整個租戶的檔案變成公開可下載,而服務還是照常啟動、健康檢查照樣綠燈——所以要在開機時就吵

這是出錯就擋下來(fail-closed),是正確的做法。 對照組:FR-081 的 jedi-issue 也是拒絕啟動(同樣好),FR-082 的 jedi-ai-bot 只靠欄位必填擋(較弱)。這支屬於做得好的那一類。


5. 卡片七個重點的逐項結論

卡片項目 結論 依據
① 上傳時用使用者可控字串拼存檔路徑(本棒最重要) 不成立 見 B1-5。被汙染的值寫進 config.base_dir,但 adapter 在更早的建構期就把目錄抄走了,寫檔讀的是抄本。額外確認:物件儲存不吃這個值、宿主沒覆寫、DI 用 Factory 也不改變順序。但這是順序意外擋住的、不是刻意防的,建議補輸入檢查
② 任何登入者可替任意檔案編號換下載憑證 **✅ 成立(HIGH) 見 B1-1。工具 3/3 票。:153 只掛 @auth_required:156 直接拿 uid 去簽。憑證不帶租戶綁定,下游也不會再驗歸屬**(正是③與 B1-3 的結論)
③ 下載與預覽的守門在無憑證時退回「只要登入就好」 **✅ 成立(HIGH) 見 B1-3。jedi-iam/jedi_iam/authz/signed_token.py:155-163st 就只跑 verify_jwt_in_request()。下游沒有任何地方再檢查歸屬**——資料層只用 uid 過濾,表上沒有租戶欄位、資料庫隔離也是關的(跨讀 jedi-iam 屬必要追脈絡)
④ 檔名與大小把關很薄 一半不成立、一半成立 見 B1-6、B1-7。穿越不成立split('.')[-1] 取不到 ..);無副檔名白名單成立(是 B1-2 的燃料);無單檔大小上限、無檔數上限成立(LOW,另記 B1-7)
⑤ 套件把守門整個外包給宿主 **不成立(設計正確) 見 B1-8。缺守門 → _assert_api_wiring 當場拒絕掛載,fail-closed**。與 jedi-issue 同級,優於 jedi-ai-bot
plugin.py 的每個預設值是否出錯就放行 無問題 逐項看過:守門三件刻意不給預設值(缺了就拒絕啟動);file_access_scope 預設凍結(改它等於讓已發出的憑證全失效);extra_object_storage_types 預設空集合,漏設的後果是送檔當場炸掉(不是靜默放行);pdf_convertible_exts 是 LibreOffice 能力清單,與安全無關。沒有一個是出錯就放行
app/ 那層的用例編排有沒有做歸屬檢查 ✅ 成立:兩層都沒做 app/service/file_upload_service.pyapp/service/upload_file_service.py 兩支從頭到尾沒有任何權限或歸屬判斷,只有 @transactionroute 層沒做、app 層也沒做——這正是 B1-1/B1-3/B1-4 之所以成立的原因

卡片沒問但順手確認的:範圍內沒有撈到任何憑證或密鑰(密鑰專項掃描與人工查證皆無)。物件儲存的憑證在宿主那側(managed_file_upload_service.py),屬 B2(CM-1653) 範圍。


6. 這份結果可信到什麼程度

分兩層講。

第一層:「這些問題是真的嗎」——可信度高。

四條發現每一條都經過三位獨立檢查員投票,全部 3/3 票通過(沒有一條是勉強過關的)。我另外自己開檔核對了三條 HIGH 的每一個行號,行號與內容都對得上。stamp 蓋的是 verified

B1-1 特別可靠:三位檢查員的理由完全一致,而且都追到了宿主那一側,確認 auth_required 接的真的只是「驗身分」。

第二層:「只有這些問題嗎」——不保證。

有四件事要老實說:

  1. 這是 low 檔次的掃描——一位研究員讀完全部 35 檔,沒有做威脅建模、沒有跑廣度掃描。深度夠、但不是地毯式。
  2. 範圍只有 35 檔。真正把檔案讀寫落地的程式(infra/adapter/)、資料庫存取層(infra/repository/)、建表腳本都不在這一棒——那是 B1b(CM-1654)。我為了判斷「下游有沒有兜底」有讀進去幾支,但那是追脈絡,不是審計。
  3. 宿主接線只讀了跟本棒判斷有關的部分。儲存後端怎麼挑、租戶憑證怎麼拿、加密開關預設是關的(首腦盤點的疑點⑤)——那是 B2(CM-1653)。
  4. 沒有實際測試。沒有真的上傳檔案、沒有真的打端點、沒有驗證任何一條攻擊路徑可以跑通。全部是讀程式碼推論。B1-5 判「不成立」也是讀出來的——建議修的時候順手寫一個測試把它釘住,因為它靠的是執行順序,重構就可能復活。

合起來看:B1-1、B1-3、B1-4 三條其實是同一個病的三個出口(只驗身分、不驗歸屬),修的時候應該當成一件事修,而不是各補各的。B1-2 則要跟 B1-6 的「無白名單」一起修,只修其中一半都還是會中。


7. 執行概況(數字表)

項目
掃描範圍 api/app/ports/common/plugin.py35 檔(與卡片數字一致)
scanRoot jedi-file-upload/(子目錄,非 monorepo 根)
commit 8aa6f0609c4434c0fa5206d5e99e31cad249646e,branch feature/FR-075,工作區乾淨(dirty: false
effort/focus lowattack-surface
執行時間 2,164 秒(約 36 分鐘)
派出 agent 20 個(全部完成,0 失敗、0 跳過、0 空回)
研究員 派出 2、回來 2
候選發現 7 條 → 去重後 6 條
面板票數 18 票(6 條 × 3 位檢查員),全數投出、0 票未投、0 條候選未審
達標發現 4 條(4 條全部 3/3 票一致通過;另 2 條 0/3 票一致否決)
嚴重度分布 CRITICAL 0/HIGH 3MEDIUM 1/LOW 0
嚴重度被下修
驗證輪數 1 輪(無續跑、無遺失候選)
stamp verifiedreason: null
工具產出 jedi-file-upload/CLAUDE-SECURITY-20260911-052906/(該目錄自帶 .gitignore,不入版控)

工具原始報告(英文,含完整 exploit 路徑):CLAUDE-SECURITY-RESULTS.md.jsonl.sarif,同上目錄。