檢查日期 2026-09-25|對應卡片 CM-2159(母卡 CM-2152)|範圍 13 個檔案、1,786 行|工具 run ID
wf_a828ae6e-597|驗證章verified
這一棒守門檢查很多,大多也問對了問題;真正的新洞在一個沒人想到要守的地方:Google 授權完成後跳回來的那一頁,可以被塞進一段程式,點一下連結就可能被盜帳號。
error 的欄位原樣寫進頁面裡的程式碼。今年 6 月曾修過一次(改用 json.dumps 包起來),但那次修法擋不住 </script> 這種寫法。我親手送了一段惡意網址,回應原封不動帶回攻擊程式碼。落地版的前端和 API 放在同一個網址底下,登入憑證又存在瀏覽器裡,所以受害者只要在登入狀態下點一條連結,登入憑證就會被偷走;受害者如果是管理員,攻擊者就拿到管理員權限。get_project_folders 沒有問題。帶著前棒的形狀來看:U5/U6 的洞在「背景工作用可看全部公司的身分跑」。這一棒的 13 支是那條線的入口,問對問題的比例很高:寫入類都問「你是不是這家公司的管理員」,平台設定問「你是不是總部管理員」。出事的是兩個「刻意不掛權限」的入口:一是回呼頁(本來就不能掛,但漏了跳脫);二是狀態查詢(為了讓一般成員看得到「有沒有連線」,卻連根資料夾編號一起回傳)。第三個是 U5 已報過的「重建資料夾」。
客戶可以把系統接上自己公司的 Google 雲端硬碟。接的時候,管理員按「連接」,系統把人帶到 Google 的同意畫面;同意後 Google 把人帶回我們的「回呼頁」,系統拿到一組授權權杖(之後代表這家公司存取雲端硬碟的憑證),加密後存進資料庫。
這一棒的 13 支程式負責這條線的「設定、授權、金鑰」:
client_id+密鑰)從哪讀。核心問題有兩個:每道守門問的問題對不對?加密金鑰從哪來、會不會出錯?
| 檔案 | 行數 | 角色 |
|---|---|---|
app/cloud_integration/service/google_drive_integration_service.py |
288 | 授權流程主體:產生授權網址、處理回呼、斷開 |
api/cloud_integration/routes/google_drive_sync_route.py |
260 | 同步維運入口:工作清單、重試、重建資料夾 |
app/system_config/service/google_drive_app_config_resolver.py |
224 | Google 應用程式帳號從哪讀、密鑰解密 |
infra/cloud_integration/google_drive/google_oauth_client.py |
180 | 打 Google 的授權端點(換票、更新、撤銷) |
app/cloud_integration/service/drive_sync_admin_service.py |
164 | 同步維運操作本體 |
api/cloud_integration/routes/google_drive_integration_route.py |
140 | 連線狀態、授權網址、回呼頁 |
domain/cloud_integration/service/google_drive_token_manager.py |
131 | 權杖過期時換新、被撤銷時標記 |
app/cloud_integration/service/drive_app_credential_verify_service.py |
94 | 「驗證憑證」按鈕的後端 |
infra/cloud_integration/repository/tenant_drive_integration_repo_impl.py |
94 | 「哪家公司接了哪個 Google 帳號」這張表的讀寫 |
api/cloud_integration/__init__.py |
84 | 網址註冊表 |
domain/cloud_integration/service/tenant_drive_integration_domain_service.py |
65 | 連線紀錄的建立、覆寫、斷開 |
api/cloud_integration/routes/drive_app_credential_verify_route.py |
44 | 「驗證憑證」網址入口 |
infra/cloud_integration/crypto/fernet_crypto.py |
18 | 加解密(Fernet,一種對稱加密法) |
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度+為什麼 | 來源 |
|---|---|---|---|---|---|---|
| U4-1 | 回呼頁把網址上的 error 原樣寫進頁面程式碼,6 月那次修法擋不住 </script> |
受害者點一條連結,攻擊程式碼就在我們的網站上執行,偷走登入憑證、冒用身分;管理員中招=管理員權限外流 | 受害者在同一個瀏覽器裡已經登入,並且點了攻擊者給的連結。攻擊者不需要任何帳號 | api/cloud_integration/routes/google_drive_integration_route.py:120(error 改成只收固定幾種錯誤代碼,不原樣寫回;至少把 < > & 轉義);:110(例外訊息 str(e) 別寫回頁面);回應加安全標頭(CSP) |
🔴 高:外人不用帳號就能發動,只需要受害者點一下 | 工具 F1,面板 3:0;runner 實測重現 |
| U4-2 | 「重建專案資料夾」收任何專案編號,不查是誰家的 | 見 U5-1/U6-1 | 見 U5-1 | app/cloud_integration/service/drive_sync_admin_service.py:125 |
🟠 同 U6-1(U6 評高) | 工具 F3,面板 3:0;U5-1/U6-1 重現,不另計 |
| U4-3 | 不掛權限的「連線狀態」查詢,回傳內容含根資料夾編號 | 根資料夾設成「知道連結就能編輯」,公司內任何登入者都能看、改、刪全公司的證據 | 同公司的任一登入帳號 | api/cloud_integration/routes/google_drive_integration_route.py:49(狀態回應拿掉 root_folder_id 和 google_account_email) |
🟠 中 | 工具 F4,面板 3:0;U5-2/U6-2 重現,不另計 |
| U4-4 | Google 應用程式密鑰明文寫在對話紀錄檔 | 能讀程式庫的人可以冒用本產品的 Google 應用程式身分 | 能讀程式庫 | — | 🟠 中(面板從高降為中) | 工具 F2,面板 3:0;CM-1631 重現,不另計 |
| U4-5 | 加密金鑰換掉或弄丟後,權杖解不開,系統不報錯、仍顯示「已連線」 | 該公司所有同步工作重試 5 次後失敗,錯誤訊息是空白;畫面照樣顯示「已連線」,一線人員無從判斷原因 | 主機的 DRIVE_TOKEN_ENCRYPTION_KEY 被改、遺失,或還原了別台的資料庫 |
domain/cloud_integration/service/google_drive_token_manager.py:60、:66(接住解密失敗,標記成「需重新授權」並寫一句白話原因) |
🟢 低(非資安,屬於出事查不出來) | runner 開檔+實測(未經三人面板) |
| U4-6 | 授權網址沒有綁定「按下連接的那個瀏覽器」 | 攻擊者把自己公司的授權網址轉給別人,對方在 Google 同意畫面按下同意,對方的雲端硬碟就接進攻擊者的公司 | 攻擊者是某家公司有「建立連線」權限的管理員;受害者願意在 Google 同意畫面上按同意 | app/cloud_integration/service/google_drive_integration_service.py:90-92(產生時把 state 也寫進發起者的瀏覽器);:107-110(回呼時核對,並改成「讀取與刪除一次完成」) |
🟢 低:受害者要自己在 Google 畫面上點同意;未實測(要真的 Google 帳號) | runner 開檔(未經三人面板) |
| U4-7 | 「驗證憑證」只檢查是不是總部的人,比同一組設定的讀寫端點少一道能力點檢查,跟它自己的註解不符 | 總部任何一個登入帳號(不必是管理員)都能按:看得到目前設定能不能用、回呼網址是什麼,也能拿伺服器去試任意一組 Google 憑證 | 總部公司的任一登入帳號 | app/cloud_integration/service/drive_app_credential_verify_service.py:71(比照讀寫端點先檢查 storage-config 能力點) |
🟢 低:拿不到密鑰本身,只拿得到「對或錯」 | runner 開檔(未經三人面板) |
現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。
- U4-1(回呼頁注入)=總表第 188 項,✅ 已修(CM-2200,commit
f0a740bd7,1.21.0 出貨)。- U4-2(重建專案資料夾)=U5-1/U6-1=總表第 192 項,✅ 已修(CM-2201,commit
73f5f90af,1.21.0 出貨)。- U4-3(狀態查詢回根資料夾編號):併 M24 第 8 條,🚫 裁定不修(決策者 10-01,雲端硬碟分享權限不由產品管)。
- U4-4(Google 密鑰寫在對話紀錄):原卡 CM-1631 作廢,✅ 已修(CM-2051,commit
ccbb27148,總表第 74 項)。- U4-6(授權連結綁瀏覽器)=總表第 190 項,✅ 已修(CM-2218,commit
a671d0b64,1.21.0 出貨)。- U4-7(驗證憑證少一道能力點)=總表第 191 項,✅ 已修(CM-2211,commit
fe928859f,1.21.0 出貨)。- U4-5(金鑰換掉仍顯示已連線,非資安):SUMMARY 無對應條目,查不到現況。
淨新增:高 1(U4-1)+低 3(U4-5/U4-6/U4-7,其中 U4-5 為非資安)。
工具交回 5 條候選,三人面板 15 票全投完、零漏投。通過 4 條、否決 1 條,研究員 2 派 2 回、零失敗。
在哪:api/cloud_integration/routes/google_drive_integration_route.py:115-140(_callback_html),入口 :86-112。
白話說明:使用者在 Google 同意授權後,Google 會把人帶回我們的 /api/1.0/integrations/google-drive/callback。這個頁面本來就不能要求登入(進來的是 Google 轉址,不是我們的前端),網址上的 error 欄位誰都能填。程式把它丟進 json.dumps 處理後,直接插進頁面裡一段 <script> 程式碼。
json.dumps 會把雙引號、反斜線處理好,但不處理 <、>、/。瀏覽器讀網頁時,只要看到 </script> 就會把那段程式碼結束掉,不管它在不在引號裡。所以攻擊者在 error 塞進 </script><script>任意程式</script>,後半段就會變成一段新的、會被執行的程式碼。
runner 實測(不打真伺服器,用 Flask 測試用戶端掛上真正的 route 類別):送出 error=</script><script>alert(document.domain)</script>,回應 200、text/html、沒有任何安全標頭,回應原文包含 error: "</script><script>alert(document.domain)</script>"。再用 Python 內建的 HTML 解析器解析這段回應,確認被切成兩個 script 元素,其中一個的內容正是 alert(document.domain)。
為什麼偷得到登入憑證:
/api/1.0 放在同一個網址底下(前端 repo nginx.onprem.conf:88-91),所以這段程式在「我們的網站」上執行。src/utils/authPersist.js:4),同網址下的程式讀得到。沿革:2026-06-05 commit 99055ab00 的 C12 修過這支。當時是從 f'"{error}"' 改成 json.dumps,commit 訊息也寫了「專案無 CSP 兜底」。程式碼註解 :118-119 至今仍寫著「用 json.dumps 跳脫所有注入…防 Reflected XSS」,這句話不成立。我查過跨 arc 總表,沒有這一條。
觸發前提:受害者在同一個瀏覽器裡已登入,並點了攻擊者的連結。攻擊者不需要任何帳號,不需要這家公司接了雲端硬碟,也不需要知道任何編號。開發環境前端和 API 不同埠號,偷不到憑證,但頁面一樣會執行程式。
建議修法:
error 只接受固定幾種錯誤代碼(例如 access_denied、missing_params),其他一律換成通用代碼,不要把使用者給的字串寫回頁面。:110 的 str(e) 也一樣。json.dumps 之後把 <、>、& 換成 <、>、&。Content-Security-Policy,只允許頁面內這一段程式。嚴重度:高(面板三票都評高)。唯一的門檻是「受害者要點連結」。
工具從 U4 這一側再撈到一次「重建專案資料夾」的跨公司問題。從入口看,病灶有兩處:
api/cloud_integration/routes/google_drive_sync_route.py:108-110 只掛了「登入」與「公司有沒有買雲端整合」,問的是「你有沒有買」,不是「這專案是不是你的」。app/cloud_integration/service/drive_sync_admin_service.py:120-123 的註解寫「FE already gates the button to project owner / manager; no extra BE check」,也就是「前端已經把按鈕藏起來了,後端不再檢查」。這正是卡片「有人守了一半」列的那一型:註解說這裡刻意不查,但上一層根本沒人真的查(前端藏按鈕不算檢查)。同一支 route 檔的隔壁 verify-and-repair-folders(:126-156)有在 service 層檢查專案管理者(drive_project_verify_service.py:158-)。所以修法有現成的樣板可以照抄。
api/cloud_integration/routes/google_drive_integration_route.py:32-49 的註解說明這支 GET 刻意不掛能力點,理由是「專案頁要用它判斷要不要顯示雲端區塊」,這個理由成立。問題是回應整份 DTO(資料傳輸物件,就是回給前端的那包資料)都帶出去了,裡面有 root_folder_id 和 google_account_email(app/cloud_integration/dto/google_drive_integration_dto.py:16、:47)。前端判斷要不要顯示只需要 status。
同一個模組裡守門不一致:新方法 get_project_folders 只回某一個專案的資料夾編號,卻要求 cloud_integration.read 能力點(google_drive_sync_route.py:175)。範圍更大的根資料夾編號反而不用任何能力點。
Google 應用程式密鑰(GOCSPX-…)明文出現在 docs/conversation-history/2026-04-21-google-drive-integration/ 的多個對話紀錄檔裡。面板把嚴重度從高降為中(三張確認票都評中)。U5 已記過,屬於 CM-1631,不另計。
三位檢查員都確認 DRIVE_TOKEN_ENCRYPTION_KEY 確實出現在同一批對話紀錄裡,而且和本機開發環境的 .env 是同一把。否決理由是只有金鑰沒用,還要另外拿到資料庫裡的密文,而程式庫裡找不到任何一段真的密文。這一條在 CM-1631 的範圍內,否決不代表可以不處理。
以下都是 runner 自行開檔核對,沒有經過三人面板投票。DEV 查詢時間 2026-09-25 13:49(隔離規則)與 14:17(筆數)。
金鑰從哪來:主機環境變數 DRIVE_TOKEN_ENCRYPTION_KEY(config/config.py:165),經 DI 容器(di_containers/cloud_integration/cloud_integration_containers.py:109)交給 FernetCrypto。每套安裝各自隨機產生一把(scripts/installer/install.sh:1260),不是全產品共用。
換公司會不會共用一把:同一套安裝裡的所有公司共用同一把,平台的 Google 應用程式密鑰也用這把(google_drive_app_config_resolver.py:26)。這是設計選擇:公司之間的權杖靠資料庫的隔離規則分開,不是靠金鑰分開。能讀到資料庫全部資料又拿到這把金鑰的人,可以解開所有公司的權杖。不列為發現,但要記住一件事:外洩一把金鑰,受影響的是全部公司,不是單一公司。
沒有換金鑰的機制:fernet_crypto.py 只用單一把 Fernet,沒有用可以同時認新舊金鑰的 MultiFernet。換金鑰=所有公司的授權一次失效。
解密失敗 → 成立,列 U4-5(非資安)。我寫了一段小程式模擬「舊金鑰加密、新金鑰解密」,打 GoogleDriveTokenManager.get_valid_access_token,結果:
cryptography 的 InvalidToken,錯誤訊息是空字串;InvalidGrantError,:69),不接解密失敗,所以連線狀態維持 CONNECTED,也不會被標成需要重新授權;drive_sync_worker.py:132-137),重試 5 次後標失敗,存進去的錯誤訊息是 str(e),也就是空白。結果:畫面顯示「已連線」、同步全部靜默失敗、失敗原因欄是空的。
換新權杖時會不會寫錯公司 → 不成立。get_valid_access_token(tenant_id) 用公司編號查出那一列,就地更新同一列(:49、:89-93);被撤銷時另開交易,也是用同一個公司編號重查(:115)。資料庫對 tenant_id 有唯一索引(DEV tenant_drive_integrations_tenant_id_key),一家公司只會有一列,不存在挑錯列的可能。
逐個端點看「掛了什麼、問的是什麼、該問什麼」:
| 端點 | 掛了什麼 | 問的問題 | 對不對 |
|---|---|---|---|
GET /integrations/google-drive 連線狀態 |
登入+有買 | 你是這家公司的人嗎 | ⚠️ 問題對,但回太多(U4-3) |
DELETE 斷開連線 |
登入+有買+cloud_integration.delete |
你是這家公司有權限的管理員嗎 | ✅ |
POST /auth-url 產生授權網址 |
登入+有買+cloud_integration.create |
同上 | ✅(但見 U4-6) |
GET /callback 回呼頁 |
無(刻意,Google 轉址進來) | 靠一次性 state 認身分 | ✅ 身分部分對;❌ 頁面輸出沒跳脫(U4-1) |
POST /verify-credentials |
登入+service 層總部檢查 | 你是總部的人嗎 | ⚠️ 少問「你有沒有權限」(U4-7) |
GET /sync-jobs 工作清單 |
登入+有買+.read |
你是這家公司有權限的管理員嗎;只查自家(:55) |
✅ |
POST /sync-jobs/<uid>/retry |
登入+有買+.update |
同上 | ✅ 程式沒限公司,但資料庫隔離規則擋得住(見 ④) |
POST /projects/<uid>/init-folders |
登入+有買 | 只問「你有沒有買」 | ❌ 該問「這專案是不是你的、你是不是它的管理者」(U4-2=U5-1) |
POST /projects/<uid>/verify-and-repair-folders |
登入+有買+service 層專案管理者 | 你是這個專案的管理者嗎 | ✅ |
GET /projects/<uid>/folders |
登入+有買+.read |
你是這家公司有權限的管理員嗎;只查自家 | ✅(見 ⑤) |
POST /tenant/rebuild-all |
登入+有買+.update |
同上;只動自家 | ✅ |
POST /webhook/register |
登入+有買+.update |
同上;只動自家 | ✅ |
cloud_integration.* 四顆能力點都不是平台級(is_platform = FALSE,scripts/init/04-seed-core.sql:184-187),只配給各家公司的管理員角色。DEV 實查 2026-09-25 13:49 看到的是各公司的「系統管理員」「Admin」類角色。對「只動自家公司資料」的操作來說,這個問題問得對。
不成立。
google_drive_app_config_resolver.py:103-108,固定讀 SYSTEM_ROOT_TENANT_ID)和主機環境變數(:111-125)。各家公司自己存的同名設定不會被讀到。guarded_system_config_service.py:259-275 在能力點之後再加一道總部管理員檢查(旗標 DRIVE_APP_CONFIG_PLATFORM_ADMIN_ONLY = True)。一般使用者和各公司管理員都寫不進去。google_drive_sync_route.py:55、:216、:252-257)。不成立。:97 → drive_sync_job_repo_impl.py:175-191):程式只用工作編號查,沒帶公司條件。但 drive_sync_jobs 開了資料庫隔離,DEV 實查 2026-09-25 13:49:UPDATE 規則是「總部身分,或這筆資料的公司在你的可見範圍內」;API 連線用的 cm_app 帳號不能繞過隔離(rolbypassrls = f)。別家公司的工作編號更新到 0 筆,回 retried: false。不成立。唯一例外是總部的人本來就看得到全部,這是平台層設計。get_project_folders(掃描後才新增的)不成立,沒有問題。 2026-09-18 commit a0ae4f378(CM-1940)新增的這支:
@transaction(drive_project_verify_service.py:115),查詢在正確的交易範圍內;drive_folder_mapping_repo_impl.py:53-65,三個條件都有),資料庫隔離又再擋一層。拿別家公司的專案編號來查,只會回兩個 null,不會回別家的資料夾編號;cloud_integration.read。唯一值得一提的是 U4-3 講的「守門不一致」:這支範圍小卻要能力點,範圍大的根資料夾編號反而不用。
| 常見型 | 這一棒有沒有 |
|---|---|
| 只驗「你是誰」沒驗「這是不是你的」 | 有:init-folders(U4-2) |
| 列表有查、單筆沒查 | 重試單一工作程式沒查,但資料庫隔離擋住,實際不成立 |
| 守門寫在 route 層、第二支路由沒掛 | 沒有:__init__.py 註冊的端點都逐一核過 |
or 串起來其中一個永遠成立 |
沒有 |
| 「查不到」和「沒權限」混成同一個回應 | 回呼頁「公司已被刪」與「state 失效」回同一個錯(google_drive_integration_service.py:121-124),這是刻意的,避免外人探出內部狀態,屬於正確做法 |
| 背景/系統身分假設「呼叫者一定是自己人」 | 本棒範圍內只有回呼頁用機器身分,且公司編號取自自己寫下的一次性 state,不成立(背景工人那段屬 U5/U6) |
| 防重放只在單一進程內有效 | state 存在 Redis,多台機器共用,不成立;但「讀取」和「刪除」分兩步做(:107-110),同一個 state 同時送兩次理論上兩次都會通過。要先拿到 state 才打得到,併入 U4-6 一起修 |
| 註解說「刻意不檢查」但上一層沒人真的檢查 | 有:init-folders 的「FE 已擋」(U4-2);狀態查詢「讀有沒有連線不是敏感資訊」,但實際回的不只這些(U4-3) |
domain/cloud_integration/service/google_drive_token_manager.py:60、:66 呼叫解密,只接 InvalidGrantError(:69)。InvalidToken,比照 _mark_revoked 把連線改成需要重新授權,並寫一句白話原因(例如「加密金鑰與資料不符,請重新連接」);背景工人把它當成終止錯誤,不要重試。google_drive_app_config_resolver.py:30-34 的註解已經刻意要求「解不開就拋」,這個方向是對的,問題只在拋出來之後沒人接。app/cloud_integration/service/google_drive_integration_service.py:90-94 產生授權網址時,把「公司編號:使用者編號」存進 Redis,以一次性的 state 當鑰匙;回呼時(:107-116)只看 state,不核對「完成授權的人是不是當初按連接的那個瀏覽器」。GETDEL),順便把「同一個 state 同時送兩次」這個小縫補起來。app/cloud_integration/service/drive_app_credential_verify_service.py:71 只呼叫 require_platform_admin();route(drive_app_credential_verify_route.py:30)只掛登入。:68-69)說這支「與這組設定的讀寫端點同一道門」。但讀寫端點是先檢查 storage-config 能力點,再檢查總部管理員(guarded_system_config_service.py:259-275)。這支只做了後半段。「總部管理員」的判定只看「是不是總部這家公司的成員」(jedi_iam/authz/platform.py:15-22,路徑只有一層就算),不看角色。storage-config.update 再檢查總部。verified,15 票零漏投,研究員 2 派 2 回。U4-1 我另外親手實測重現了。打折處:
cm_app 身分跑一次跨公司的 UPDATE。| 項目 | 數值 |
|---|---|
| 範圍 | 13 檔/1,786 行(開跑前 `git ls-files |
| 工具參數 | --effort low、focus attack-surface、scanRoot=BE repo |
| run ID | wf_a828ae6e-597 |
| 報告目錄 | CLAUDE-SECURITY-20260925-053527/(不進版控) |
| 版本 | 159a2fff977c(工作區有平行線未 commit 改動,stamp 標 -dirty) |
| 驗證章 | verified,5 候選、15 票、零漏投 |
| 研究員/面板 | 研究員 2 派 2 回;agent 17 派 17 回、0 失敗 |
| 耗時 | 約 35 分鐘 |
| 子 agent 用量 | 約 118 萬 token |
| 結果 | 工具通過 4 條(高 1、中 3),3 條為既有重現;runner 另補低 3 條 |