Guidant AI 資安檢視總報告 · 連線清單
Google Drive OAuth 授權回呼,HTTPS 經前門
管理員按「連線 Google 硬碟」、到 Google 同意、再被 Google 導回我們系統的那一下。這是整套產品裡唯一一個由伺服器直接產出、不需登入就打得到的網頁——Google 要能把人導回來,回呼頁就不能要求 JWT。它與一般業務 API 的差別不在協定,在「身分從哪裡來」:不是從權杖,是從一個發起時埋下、回來時核對的狀態碼與瀏覽器綁定值。這一頁回答:這條線怎麼連、哪些功能走它、它帶來什麼威脅、駭客怎麼打、我們要怎麼防、目前做到哪。
這一頁怎麼來的:DFD Level 0 把產品運作時的連線編成 C01~C25;STRIDE 六頁每一條問題都標了發生在哪幾條線。這裡以連線為單位,把這條線的特性、掃描在這條線上抓到的攻擊手法、以及由此該有的防線整理成一頁。個別問題修了沒不在這頁講,請點條目連回 STRIDE 頁。
%%{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
ADMIN(["👤 管理員瀏覽器<br/>(設定頁+彈出視窗)"])
ATK(["🕵 攻擊者<br/>(不需帳號)"])
G(["☁️ Google<br/>同意畫面"])
FE["guidant-fe<br/>nginx 前門"]
subgraph API["guidant-api :8000"]
direction TB
START["① 發起:POST …/auth-url<br/>驗 JWT・能力點・授權<br/>產 state 存 Redis(10 分鐘)<br/>產瀏覽器綁定值 → HttpOnly cookie"]
CB["③ 回呼頁:GET …/callback<br/>不需登入<br/>state 一次性核對+cookie 綁定核對<br/>回固定代碼的小頁面(CSP nonce)"]
XCH["④ 換票:authorization code → token<br/>加密落庫"]
CB --> XCH
end
RD[("guidant-redis<br/>state → 租戶:使用者:綁定值雜湊")]
ADMIN == "C02 · 已登入" ==> FE --> START
START -- "C06" --> RD
ADMIN -. "② 跳轉同意" .-> G
G -. "③ 302 導回<br/>?code=…&state=…" .-> ADMIN
ADMIN == "C04 · 不帶 JWT<br/>帶 state+cookie" ==> FE --> CB
ATK -. "C04 · 同一個網址<br/>任何人都打得到" .-> FE
CB -- "C06 讀後即刪" --> RD
XCH -. "C14" .-> G
classDef ext fill:#FBFCFC,stroke:#9AA8AA,stroke-dasharray:3 2
classDef open fill:#FDECEC,stroke:#C0392B
class ADMIN,ATK,G ext
class CB open
| 項目 | 現況 |
|---|---|
| 誰 → 誰 | 管理員的瀏覽器(被 Google 302 導回來的那個彈出視窗)→ 前門 → guidant-api。協定上與 C02 同一條管子(HTTPS 經前門、/api/1.0 前綴),單獨編號是因為它是唯一不帶 JWT 的業務路由 |
| 協定/埠 | HTTPS :443(經前門)。路徑 GET /api/1.0/integrations/google-drive/callback?code=…&state=…(失敗時 Google 改帶 error=…) |
| 傳什麼 | 進來:Google 給的一次性授權碼 code、我們發起時給 Google 的狀態碼 state、發起時寫在瀏覽器的綁定 cookie drive_oauth_nonce。出去:一個極小的 HTML 頁,用 postMessage 把「成功/失敗代碼」丟給開它的主視窗後自行關閉 |
| 怎麼驗身分 | 不驗 JWT。身分從兩樣東西拼出來:① state——發起時在一個已登入的請求裡產生(token_urlsafe(32),不可猜),對應值「租戶:使用者:綁定值雜湊」存 Redis、10 分鐘過期、查到即刪(一次性,對不上也作廢);② drive_oauth_nonce cookie——發起時另產的隨機值,明文只在發起者瀏覽器(HttpOnly、SameSite=Lax、路徑限回呼網址、https 時 Secure),伺服器只存雜湊,回呼時定時比對。兩樣都對才認「這是那家公司的那位管理員,從他自己的瀏覽器回來的」。回呼頁不論成敗都把 cookie 清掉 |
| 發起端的守門 | POST …/auth-url 要 JWT+能力點 cloud_integration.create+授權模組 cloud_integration;另檢查發起頁的來源網址等於系統設定的對外網址(不等就先告訴管理員改用哪個網址開,否則回呼時 cookie 跨網域帶不過去必失敗) |
| 回呼頁的輸出 | 網址上的 error 只認白名單(access_denied),其餘一律對成固定代碼;例外訊息不回顯、只進後端紀錄;內嵌值跳脫 < > &;回應帶 Content-Security-Policy: default-src 'none'; script-src 'nonce-…'; base-uri 'none'; frame-ancestors 'none',只准執行帶本次隨機碼的那一段程式。主視窗收 postMessage 時核對 event.origin、event.source 與訊息標記 |
| 換票之後 | 用授權碼向 Google 換 access/refresh token(C14),查 Google 帳號 email,確認該 Google 帳號沒被另一家客戶綁走,token 加密落庫。這段在 state 解出的租戶身分下寫入(RLS 要有身分才寫得進) |
| 何時存在 | 依設定——客戶在系統設定填了 Google 應用程式帳號(GOOGLE_DRIVE_APP_CONFIG)才有這條線 |
依據:api/cloud_integration/__init__.py 路由登記、api/cloud_integration/routes/google_drive_integration_route.py(GoogleDriveAuthUrlRoute/GoogleDriveCallbackRoute/_callback_response()/_set_browser_nonce_cookie())、app/cloud_integration/service/google_drive_integration_service.py(build_auth_url()/handle_callback()/_assert_opened_from_site_url())、common/constant/drive_app_config.py、FE GoogleDriveIntegrationCard.vue。
只有一個:系統設定 → 雲端硬碟 → 連線 Google 硬碟(把一個 Google 帳號的硬碟接進這家公司,之後證據檔同步、資料夾建立都靠這組授權)。對威脅分析分兩群看:
| 群 | 功能 | 對威脅的意義 |
|---|---|---|
| ① 回呼頁本身 | 不需登入、伺服器產出的 HTML、在本站網域裡執行 | 網路上任何人都能做一個指向它的連結;頁面裡任何「把網址參數放進頁面」的寫法,都等於讓陌生人在本站網域寫程式。這是全產品唯一一個這種頁,但「唯一」是今天的事——下一個 OAuth 整合(例如別家雲端硬碟、或哪天把 Google 登入接回來)就會是第二個 |
| ② 回呼完成後的綁定 | 把 Google 帳號與這家客戶綁在一起、token 落庫、之後自動同步檔案 | 回來的人與發起的人若不是同一個,就是把別人的硬碟接進攻擊者的公司;反過來,發起連結若能被別人用,就是攻擊者替受害公司接上攻擊者的硬碟 |
掃描在這條線上抓到 3 條問題(M24-1、M24-10、M13-5),歸納起來是三種攻擊手法。每種寫駭客實際的步驟與得手什麼,條目是實例。
| # | 風險 | 威脅 | 駭客怎麼打 | 得手什麼 | STRIDE | 實例 |
|---|---|---|---|---|---|---|
| T1 | 🟠 高 | 不需登入的頁面把網址參數放進頁面程式,在本站網域執行 | ① 不需帳號,做一個指向回呼網址的連結,error 參數塞 </script><script>… 開頭的特製文字;② 寄給一個已登入的使用者(最好是管理員);③ 對方一點,頁面被切成兩段,攻擊者的程式在本站網域、以受害者的登入身分跑;④ 讀走瀏覽器裡的登入權杖送出去;⑤ 用他的名義看、改、刪他看得到的一切 |
受害者的登入身分;受害者是管理員就是整家公司 | S 冒充身分、E 權限提升 | M24-1 雲端硬碟授權回呼頁,點一個連結就被偷走登入身分 🟠 |
| T2 | ⚪ 低 | 授權連結轉寄給別人代按,把別人的硬碟接進自己公司 | ① 攻擊者在自己公司按「連線 Google 硬碟」,拿到授權連結(上面印著狀態碼);② 把連結轉寄給別家公司的人,騙他按同意;③ 對方用自己的 Google 帳號同意,Google 把他導回我們的回呼網址;④ 回呼只認狀態碼,狀態碼對應的是攻擊者的公司——對方的硬碟被接進攻擊者的公司,對方放進去的檔案攻擊者全看得到、還會被同步成攻擊者公司的證據 | 別人的雲端硬碟內容 | S 冒充身分、T 竄改資料 | M24-10 雲端硬碟授權連結沒綁定發起的瀏覽器 ⚪ |
| T3 | 🟠 高 | 「用 Google 身分」的入口沒真的向 Google 驗證,自稱是誰就是誰 | ① 在綁定 API 指定 Google、自己填一個 Google 帳號編號,系統原樣存下;② 或直接打登入 API 指定 Google,宣稱自己是某個已綁定的 Google 帳號;③ 系統從沒向 Google 查證,宣稱什麼就是什麼 | 自稱任何綁過 Google 的使用者並登入 | S 冒充身分 | M13-5 用 Google 登入是沒接通的空殼 🟠 |
三種手法的共同點:T1 與 T2 都利用「回呼頁不驗 JWT,身分只能靠回來時帶的東西拼出來」——拼得不完整(只認網址上的狀態碼、不認瀏覽器)就能被轉寄代按,頁面本身又在本站網域裡,網址參數一進頁面就是本站身分。T3 是同一個主題的另一面——「Google 說他是誰」這件事必須真的去問 Google,少了那一條 api → Google 的驗證線,整個 OAuth 就只是一個自填表單。這些是「以外部身分提供者換取本站身分」這類流程的本質,不是某支程式的 bug——Drive 這條修好了,下一個 OAuth 整合會從頭再走一遍同樣的路。
對應三種威脅與這條線的本質,防線分五條。D1~D3 直接對應掃描抓到的三種手法;標 ◇ 的(D4、D5)是依這條線的特性補的標準防線,掃描範圍沒涵蓋、沒有實例。「目前」欄寫程式與部署裡實際有的機制;⚠️ 表示目前只靠慣例或只守到局部,沒有機制保證。
| # | 防線 | 擋哪種威脅 | 目前做到哪 |
|---|---|---|---|
| D1 | 不需登入的頁面,網址參數不得進頁面程式。外部值只認白名單代碼,顯示文字由前端查翻譯;內嵌前跳脫;頁面帶 CSP 只准執行帶隨機碼的程式;主視窗收訊息時核對來源 | T1 | ✅ 三道都有:_url_error_code() 白名單、_script_literal() 跳脫 < > &、_callback_response() 帶 CSP nonce+frame-ancestors 'none';例外訊息只進後端紀錄;FE 核對 event.origin/event.source/訊息標記。⚠️ 這套寫在 Drive 這一支路由裡,不是共用元件——下一個不需登入的伺服器渲染頁要照抄,沒有機制保證它會 |
| D2 | 回呼要同時認「連結是真的」與「回來的是發起的瀏覽器」。狀態碼一次性、短效、不可猜、對不上即作廢;另一個綁定值只走發起者瀏覽器的 cookie(HttpOnly、路徑限回呼、SameSite=Lax),伺服器存雜湊、定時比對 | T2 | ✅ build_auth_url() 產 state(Redis 10 分鐘)+browser_nonce(cookie);handle_callback() 先刪 state 再比對雜湊,缺 cookie、不符、舊格式一律 GRC_400134 不換票不寫表;回呼頁不論成敗清 cookie。✅ 發起端另檢查發起頁網址=系統對外網址,避免 cookie 跨網域帶不過去。⚠️ 驗證打折:本機無真 Google,實際換票與落庫那一段沒走到 |
| D3 | 以外部身分提供者登入或綁定,必須真的向提供者驗證(驗簽、驗受眾、驗效期),不能收呼叫者自填的帳號編號 | T3 | ✅ 決策者裁定關閉入口:登入方式白名單(密碼、AD、LDAP),Google 在格式層就 400、對照表拔除後回 405;綁定端派送表移除 Google、主專案序列化器只收 AD、LDAP;Google adapter 保留但檔頭寫明「重新啟用前必須先補憑證驗證」。⚠️ 是「關掉」不是「修好」——程式還在,哪天有人把對照表加回去就是原樣復活;沒有測試擋「Google 分支不得回到對照表」 |
| D4 ◇ | 回呼端點的濫用上限:狀態碼查無、cookie 不符這類失敗要有速率限制與告警,不讓人用這個不需登入的端點拿 Redis 或日誌當靶 | 這條線是不需登入的入口,任何人可無限次打 | ⚠️ 沒有。失敗只記 warning;前門對這條路徑沒有速率限制(C01 D7 同一個缺)。每次打都查一次 Redis,狀態碼不可猜所以查不中,但日誌會被灌 |
| D5 ◇ | 換票與落庫在最小身分下進行:回呼解出的租戶身分只用於這一次寫入,不得殘留;Google 帳號一租戶一綁,不得被第二家接走 | 回呼完成後的綁定被挪用 | ✅ tenant_context("google_drive_oauth_callback", tenant_id) 包住寫入,出了 with 就沒身分;_assert_account_not_held_by_other_tenant() 擋同一個 Google 帳號被兩家綁。⚠️ 「機器身分」這條路(不是使用者 JWT、是系統替某租戶寫)的使用清單沒有盤過,這裡是其中一處 |
最便宜的一步:D1 的三道防線抽成一個「不需登入的伺服器渲染頁」共用 helper(白名單代碼+跳脫+CSP nonce 一次給),下一個 OAuth 回呼直接用,比等掃描再抓一次便宜。D3 補一條守衛測試「auth_factory.TYPE 不得含 google」,把「關掉」從慣例變機制。
scripts/、api/ 裡有沒有其他回 HTML 的端點,沒盤過。tenant_context)使用點清單(D5):系統替某租戶寫入的每一處都是「沒有使用者身分卻能寫」的點,應列清單逐一核對觸發條件。依據:STRIDE 六頁信任邊界連線標記(CM-2403/2404 驗收後版本)、api/cloud_integration/routes/google_drive_integration_route.py、app/cloud_integration/service/google_drive_integration_service.py、common/constant/drive_app_config.py、套件 jedi-iam infra/adapter/auth_factory.py、FE GoogleDriveIntegrationCard.vue、DFD Level 0。M13-5 嚴格說是 Google 登入而非 Drive 回呼,首腦標在 C04 是因為「應該要有而沒有」的那條 api → Google 驗證線與本線同屬 OAuth 主題,照標記列入。