---
title: C04 瀏覽器 → api（Drive OAuth 回呼）
eyebrow: Guidant AI 資安檢視總報告 · 連線清單
h1: C04 瀏覽器 → api（Google Drive OAuth 授權回呼，HTTPS 經前門）
lede: 管理員按「連線 Google 硬碟」、到 Google 同意、再被 Google 導回我們系統的那一下。這是整套產品裡**唯一一個由伺服器直接產出、不需登入就打得到的網頁**——Google 要能把人導回來，回呼頁就不能要求 JWT。它與一般業務 API 的差別不在協定，在「身分從哪裡來」：不是從權杖，是從**一個發起時埋下、回來時核對的狀態碼與瀏覽器綁定值**。這一頁回答：這條線**怎麼連**、**哪些功能走它**、它帶來**什麼威脅、駭客怎麼打**、我們**要怎麼防、目前做到哪**。
chips:
  - { text: "依設定（Drive）", kind: plain }
  - { text: "不需登入・state＋瀏覽器綁定", kind: warn }
  - { text: "威脅 3 種", kind: accent }
  - { text: "掃描命中 3 條", kind: plain }
---

> **這一頁怎麼來的**：[DFD Level 0](../DFD/dfd-level0.html#c04) 把產品運作時的連線編成 C01～C25；[STRIDE 六頁](../STRIDE/S-spoofing.html)每一條問題都標了發生在哪幾條線。這裡以**連線**為單位，把這條線的特性、掃描在這條線上抓到的攻擊手法、以及由此該有的防線整理成一頁。個別問題修了沒不在這頁講，請點條目連回 STRIDE 頁。

## 一、連線圖

```{.mermaid cap="C04 — Google Drive 授權回呼。① 管理員在設定頁（已登入、走 C02）按「連線」，api 產一個一次性狀態碼存 Redis、另產一個瀏覽器綁定值寫進 cookie；② 瀏覽器跳到 Google 同意；③ Google 把瀏覽器導回我們的回呼網址（這一下不帶 JWT，就是 C04）；④ api 用狀態碼找回「是哪家公司、誰發起的」、用 cookie 確認「回來的是發起的那個瀏覽器」，才拿授權碼向 Google 換票（C14）。紅色是不需登入就打得到的入口。"}
%%{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](C02-frontdoor-to-api.html) 同一條管子（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](../DFD/dfd-level0.html#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](../STRIDE/E-elevation-of-privilege.html#m24-1)、[M24-10](../STRIDE/S-spoofing.html#m24-10)、[M13-5](../STRIDE/S-spoofing.html#m13-5)），歸納起來是**三種攻擊手法**。每種寫駭客實際的步驟與得手什麼，條目是實例。

| # | 風險 | 威脅 | 駭客怎麼打 | 得手什麼 | STRIDE | 實例 |
|---|---|---|---|---|---|---|
| T1 | 🟠 高 | **不需登入的頁面把網址參數放進頁面程式，在本站網域執行** | ① 不需帳號，做一個指向回呼網址的連結，`error` 參數塞 `</script><script>…` 開頭的特製文字；② 寄給一個已登入的使用者（最好是管理員）；③ 對方一點，頁面被切成兩段，攻擊者的程式在本站網域、以受害者的登入身分跑；④ 讀走瀏覽器裡的登入權杖送出去；⑤ 用他的名義看、改、刪他看得到的一切 | 受害者的登入身分；受害者是管理員就是整家公司 | [S 冒充身分](../STRIDE/S-spoofing.html)、[E 權限提升](../STRIDE/E-elevation-of-privilege.html) | [M24-1 雲端硬碟授權回呼頁，點一個連結就被偷走登入身分](../STRIDE/E-elevation-of-privilege.html#m24-1) 🟠 |
| T2 | ⚪ 低 | **授權連結轉寄給別人代按，把別人的硬碟接進自己公司** | ① 攻擊者在自己公司按「連線 Google 硬碟」，拿到授權連結（上面印著狀態碼）；② 把連結轉寄給別家公司的人，騙他按同意；③ 對方用自己的 Google 帳號同意，Google 把他導回我們的回呼網址；④ 回呼只認狀態碼，狀態碼對應的是攻擊者的公司——**對方的硬碟被接進攻擊者的公司**，對方放進去的檔案攻擊者全看得到、還會被同步成攻擊者公司的證據 | 別人的雲端硬碟內容 | [S 冒充身分](../STRIDE/S-spoofing.html)、[T 竄改資料](../STRIDE/T-tampering.html) | [M24-10 雲端硬碟授權連結沒綁定發起的瀏覽器](../STRIDE/S-spoofing.html#m24-10) ⚪ |
| T3 | 🟠 高 | **「用 Google 身分」的入口沒真的向 Google 驗證，自稱是誰就是誰** | ① 在綁定 API 指定 Google、自己填一個 Google 帳號編號，系統原樣存下；② 或直接打登入 API 指定 Google，宣稱自己是某個已綁定的 Google 帳號；③ 系統從沒向 Google 查證，宣稱什麼就是什麼 | 自稱任何綁過 Google 的使用者並登入 | [S 冒充身分](../STRIDE/S-spoofing.html) | [M13-5 用 Google 登入是沒接通的空殼](../STRIDE/S-spoofing.html#m13-5) 🟠 |

**三種手法的共同點**：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」，把「關掉」從慣例變機制。

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

- **不需登入的伺服器渲染頁清單**（D1）：目前只知道這一頁。登入頁、忘記密碼頁是前端渲染不算；但 `scripts/`、`api/` 裡有沒有其他回 HTML 的端點，沒盤過。
- **回呼端點的失敗率監看**（D4）：狀態碼查無、綁定不符的 warning 有沒有人看、有沒有閾值，沒有。
- **機器身分（`tenant_context`）使用點清單**（D5）：系統替某租戶寫入的每一處都是「沒有使用者身分卻能寫」的點，應列清單逐一核對觸發條件。
- **Google 登入復活的守衛**（D3）：關閉靠對照表少一列，沒有測試擋回歸。

---

*依據：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 主題，照標記列入。*
