只要有一個能登入的帳號,再知道某個檔案的編號,就能把別家客戶的檔案下載走、線上預覽、還能永久刪掉——系統從頭到尾只檢查「你有沒有登入」,從來沒檢查過「這個檔案是不是你的」。
而且還能替別人的檔案換發一張下載通行證,那張證不需要帳號密碼就能開檔,可以直接把網址傳給公司外面的任何人。
這四條全部是同一個病:認得出你是誰,但不管你能碰什麼。
好消息有一個:卡片列為「本棒最重要」的①路徑穿越(把檔案存到不該存的地方)經查不成立——程式的寫法剛好讓攻擊字串到不了真正寫檔的那一步。詳見第 4 節 B1-5。
jedi-file-upload 是產品裡所有檔案功能的對外門面。使用者做四件事都走它:
window.open 或 <iframe> 開檔時帶不了登入憑證,所以先換一張「證」放在網址上)這一棒要回答三個問題:能不能把檔案寫到不該寫的地方、能不能下載到別人的檔案、檔名與大小有沒有把關。
只找問題、不修問題——底下所有修法都只是建議,這一棒沒有動任何一行程式碼,也沒有真的上傳或下載任何檔案去測(全部是讀程式碼推論出來的)。
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 嚴重度 | 來源 |
|---|---|---|---|---|---|---|
| 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 節,每一項都有「成立/不成立」。
這是什麼問題(白話)
瀏覽器用 window.open 或內嵌框架開檔案時,沒辦法帶上登入憑證。所以系統設計了一個折衷:前端先用正常的登入身分去跟後端要一張短期通行證(程式裡叫 st),這張證會綁定「哪一個檔案」,然後把它接在網址後面去開檔。
問題是——發證的那個窗口只檢查「你有沒有登入」,完全不檢查「你要的這個檔案是不是你的」。
你拿自己的帳號,報上別人的檔案編號,它就發一張綁著別人檔案的通行證給你。
出事會怎樣
拿到那張證之後有兩件事特別糟:
要先有什麼才打得到
在哪裡
jedi_file_upload/api/routes/upload_file_route.py:153 — 只掛了 @auth_required(「有登入就好」)jedi_file_upload/api/routes/upload_file_route.py:156 — signed_token_issuer(..., resource_uid=uid),直接拿使用者給的 uid 去簽compliance-manager-be/core/upload_file_wiring.py:133 — auth_required 接的是純粹的 jwt_required(),只驗身分不驗權限怎麼修
在 upload_file_route.py 的 UploadFileAccessTokenRoute.get() 裡,簽證之前先把檔案撈出來,確認呼叫者有權讀它(是本人上傳的?同一個租戶?是所屬專案的成員?),不符就 raise ForbiddenError。
因為套件本身不知道產品的歸屬規則,正解是多開一個必填的 port(例如 adapters.file_access_authorizer(uid) -> bool),跟現有的守門三件放在一起,並在 plugin.py:282 的 _assert_api_wiring 一併檢查——宿主忘了接就開不起來,而不是靜默地把所有檔案變成憑編號即可取用。
這是什麼問題(白話)
預覽功能的邏輯是:Office 檔先轉成 PDF 再顯示;其他格式就原檔直接丟給瀏覽器顯示(as_attachment=False 的意思就是「不要下載,直接在頁面裡開」)。
而「這是什麼類型的檔案」是依使用者上傳時的副檔名決定的,而且副檔名沒有白名單(我整包 grep 過 secure_filename、白名單、MIME 驗證,一個都沒有)。
所以上傳一個 .html 或 .svg,系統就會老老實實地告訴瀏覽器「這是網頁,請執行它」。
出事會怎樣
那段程式是在我們自己的網站網域底下執行的,所以瀏覽器認為它是自己人:
nosniff、沒有 X-Frame-Options),所以沒有東西擋得住它預覽網址正是前端內嵌框架平常在開的那個網址——這條路是產品的正常流程,不是什麼刁鑽路徑。
要先有什麼才打得到
在哪裡
jedi_file_upload/api/routes/upload_file_route.py:251 — 依副檔名猜出 MIME 類型jedi_file_upload/api/routes/upload_file_route.py:259-262 — send_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 — 副檔名照抄使用者給的檔名怎麼修
as_attachment=True + application/octet-stream(變成下載,不是執行)X-Content-Type-Options: nosniff 與嚴格的 Content-Security-Policy(例如 default-src 'none'; sandbox)這是什麼問題(白話)
下載端點掛的守門叫「雙軌」——帶了通行證(st)就走通行證那條,沒帶就退回「只要有登入就好」。
退回的那條什麼都不綁:它只確認「你是一個合法使用者」,完全不比對「這個檔案跟你有沒有關係」。
再往下看也沒有兜底:資料層撈檔案時只用 uid 這一個條件,而存檔案的那張表根本沒有租戶欄位、資料庫隔離也是關的(建表腳本自陳),所以資料庫層也救不了。
出事會怎樣
別家客戶的所有上傳檔案都可以被讀走:稽核證據、SSP 文件、問卷答案、合規佐證。
更麻煩的是走通行證那條時,程式會把租戶標成「無」,而系統把「無租戶」當成最高權限、跳過所有隔離——等於連資料庫那層本來就很薄的保護也一起繞過。
要先有什麼才打得到
在哪裡
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/哪題問卷底下),確認呼叫者的租戶與角色有權看它。
治本的做法二擇一:
upload_files 表加租戶欄位並開啟資料庫層隔離(讓資料庫成為最後一道防線)這是什麼問題(白話)
刪除端點同樣只掛「有登入就好」,拿到編號就刪,沒有任何一層檢查這個檔是不是你的——route 沒查、宿主的 service 也沒查。
為什麼是 MEDIUM 不是 HIGH:影響面比前三條窄一點——它破壞資料但不外洩資料,而且刪除通常會被使用者很快發現(檔案不見了)。但刪掉就救不回來,且刪的是稽核證據。
出事會怎樣
要先有什麼才打得到
在哪裡
jedi_file_upload/api/routes/upload_file_route.py:113 — 只掛 @auth_requiredjedi_file_upload/api/routes/upload_file_route.py:116 — delete_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 與呼叫者,不符就擋。
這是卡片列為「本棒最重要」的一條,結論是不成立。因為證偽跟證實一樣有價值,這裡寫清楚是什麼擋住了。
原本擔心什麼
upload_file_route.py:101 這一行:
save_dir = str(os.path.join(payload.get('upload_type'), payload.get('uid')))這兩個值來自請求裡的一段 JSON,完全由呼叫端填,而且看不到任何 ../ 過濾。照常理推論,傳 upload_type="../../etc" 就能把檔案寫到系統目錄去(這叫路徑穿越,就是用 ../ 這種寫法讓檔案被存到或讀到原本不該碰的目錄)。:134 批次上傳那條是同款寫法。
為什麼不成立
追下去發現這個被汙染的字串走到一半就死了,寫檔那一步根本讀不到它:
app/service/file_upload_service.py:82 呼叫 set_save_dir(save_dir)set_save_dir(:68)寫的是 self.upload_file_adapter.config.base_dir = save_dir.upload_file_adapter 這個屬性(:37-40)會先跑 _ensure_initialized(),那一步當場把 adapter 建出來LocalFileUploadAdapter.__init__(infra/adapter/local/local_file_adapter.py:38)在建構當下就把目錄抄成自己的 self.save_dir:57)讀的是 self.save_dir——那份在第 4 步就抄好了,第 2 步的寫入晚了一步、寫進了一個沒人再讀的地方順序是:先建構(抄走目錄)→ 才寫入(改到沒人讀的欄位)。攻擊字串永遠追不上。
我額外確認過的三件事(因為這條是卡片的重點,不能只信工具的票):
save_file(infra/adapter/minio/minio_adapter.py:92)用的是 self.config.bucket_name,跟 base_dir 無關,而且 bucket 名是設定檔來的、不是使用者填的ManagedFileUploadService,它沒有覆寫 set_save_dir 或 upload_files,繼承的就是上面那套死路(覆寫 base_dir 的另外兩支 upload_files_for_tenant / upload_system_file 是背景 job 與系統資產專用,不接使用者請求)providers.Factory(di_containers/upload_file/upload_file_containers.py:29,每個請求建新的)——我本來擔心「每次都新建 adapter 會不會讓寫入趕上」,但新建反而讓第 3、4 步每次都重跑,順序不變、結論不變但有一件事要記著:這是靠執行順序意外擋住的,不是有人刻意防的。哪天有人重構掉那個 lazy 建構、或改成先設定再建 adapter,這個洞就會自己長回來。set_save_dir 那行值得補一道輸入檢查(只允許安全字元、拒絕 .. 與絕對路徑),把它變成有意的防護。
拆成兩半看,一半不成立、一半成立:
不成立的那半:路徑穿越
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_filename、allowed_ext、白名單、MIME 驗證——一個都沒有。使用者說這是 .html 它就記 .html。
這件事本身不會造成路徑穿越,但它就是 B1-2 的燃料——沒有白名單,才會有「上傳網頁檔然後被當網頁執行」。修法併入 B1-2。
(工具未報,人工查證)
整包套件查不到任何大小上限或檔數上限的設定。批次上傳端點(:130)用 getlist('file') 收檔,要幾個收幾個。
唯一存在的上限是主專案全域的 MAX_CONTENT_LENGTH 50MB(CM-1587 定的),但那擋的是整個請求,不是單檔、也不是檔數。
為什麼只評 LOW:要打到它需要能登入,而且效果是磁碟吃緊/服務變慢,不是資料外洩;50MB 的全域上限也讓單次傷害有天花板。但累積型的塞爆(反覆上傳到把磁碟或物件儲存塞滿)沒有東西擋。
建議:在 plugin.py 的 FileUploadConfig 加兩個參數(單檔上限、單次檔數上限),在 upload_files 進迴圈前檢查,超過就 raise BadRequestError。
(工具未報,人工查證)
擔心的是:套件自己不做任何判斷,全靠宿主注入守門,那宿主忘了接會怎樣?靜默掛上一個沒防護的端點嗎?
不會。 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 只靠欄位必填擋(較弱)。這支屬於做得好的那一類。
| 卡片項目 | 結論 | 依據 |
|---|---|---|
| ① 上傳時用使用者可控字串拼存檔路徑(本棒最重要) | 不成立 | 見 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-163 無 st 就只跑 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.py 與 app/service/upload_file_service.py 兩支從頭到尾沒有任何權限或歸屬判斷,只有 @transaction。route 層沒做、app 層也沒做——這正是 B1-1/B1-3/B1-4 之所以成立的原因 |
卡片沒問但順手確認的:範圍內沒有撈到任何憑證或密鑰(密鑰專項掃描與人工查證皆無)。物件儲存的憑證在宿主那側(managed_file_upload_service.py),屬 B2(CM-1653) 範圍。
分兩層講。
第一層:「這些問題是真的嗎」——可信度高。
四條發現每一條都經過三位獨立檢查員投票,全部 3/3 票通過(沒有一條是勉強過關的)。我另外自己開檔核對了三條 HIGH 的每一個行號,行號與內容都對得上。stamp 蓋的是 verified。
B1-1 特別可靠:三位檢查員的理由完全一致,而且都追到了宿主那一側,確認 auth_required 接的真的只是「驗身分」。
第二層:「只有這些問題嗎」——不保證。
有四件事要老實說:
low 檔次的掃描——一位研究員讀完全部 35 檔,沒有做威脅建模、沒有跑廣度掃描。深度夠、但不是地毯式。infra/adapter/)、資料庫存取層(infra/repository/)、建表腳本都不在這一棒——那是 B1b(CM-1654)。我為了判斷「下游有沒有兜底」有讀進去幾支,但那是追脈絡,不是審計。合起來看:B1-1、B1-3、B1-4 三條其實是同一個病的三個出口(只驗身分、不驗歸屬),修的時候應該當成一件事修,而不是各補各的。B1-2 則要跟 B1-6 的「無白名單」一起修,只修其中一半都還是會中。
| 項目 | 值 |
|---|---|
| 掃描範圍 | api/+app/+ports/+common/+plugin.py,35 檔(與卡片數字一致) |
| scanRoot | jedi-file-upload/(子目錄,非 monorepo 根) |
| commit | 8aa6f0609c4434c0fa5206d5e99e31cad249646e,branch feature/FR-075,工作區乾淨(dirty: false) |
| effort/focus | low/attack-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 3/MEDIUM 1/LOW 0 |
| 嚴重度被下修 | 無 |
| 驗證輪數 | 1 輪(無續跑、無遺失候選) |
| stamp | verified(reason: null) |
| 工具產出 | jedi-file-upload/CLAUDE-SECURITY-20260911-052906/(該目錄自帶 .gitignore,不入版控) |
工具原始報告(英文,含完整 exploit 路徑):CLAUDE-SECURITY-RESULTS.md / .jsonl / .sarif,同上目錄。