Roy-Try-Catch
← Back to list

【資安】Cloudflare Access 實戰:不用 VPN 保護後台與 Grafana

Roy • Updated 2026-10-15 10:02:25

前言

自己養一台 VM,上面跑著 Grafana、Jenkins,還有幾個對外公開的服務,總會遇到同一個問題:這些「後台工具」到底該怎麼保護?Grafana 的登入頁、Jenkins 的管理介面、服務裡某個除錯工具的路徑,它們都不應該讓全世界的人直接打開。以前我的做法很土砲:能加 Basic Auth 就加,加不了就靠應用程式自己的登入頁,再用強密碼硬撐。後來把整台 VM 前面都放上 Cloudflare proxy 之後,我改用 Cloudflare Access 把這些入口通通包起來,不用架 VPN,也不用在每個服務各自維護一套帳密。這篇文章整理我的實戰過程與踩坑心得。

【系列】自架 VM 維運實戰 第 1 篇/共 5 篇
上一篇:(本篇是第一篇)
下一篇:【CI/CD】Jenkins 打造安全的 Laravel 部署:JCasC 與受限 SSH

先把常見的幾種做法放在一起比較,這樣你就知道為什麼我最後選了 Access:

做法 優點 缺點
VPN(WireGuard、OpenVPN 等) 網路層隔離,服務完全不對外 要維護 VPN 伺服器與每個人的設定檔;手機、臨時換裝置都麻煩;行動時還得記得先連線
Basic Auth(nginx 等) 設定簡單,幾行就好 共用一組帳密、沒有個人身分;密碼管理與輪替靠人;沒有 MFA;Jenkins 的 API client 還會被擋
應用程式內建登入 每個服務原生支援 每套各自一組帳號;有些服務的登入頁本身就是攻擊面(爆破、未修補的漏洞)
Cloudflare Access(Zero Trust) 在請求到達你的主機前就先驗身分;可用 email、IdP 管理誰能進;有 Policy 與登入紀錄 只保護「經過 Cloudflare」的流量,必須搭配 origin 防護;Policy 設錯就是白做

Access 的核心想法很簡單:不要先讓請求到你的伺服器,再問你是誰;而是在 Cloudflare 邊緣就先確認身分,通過了才轉發。沒通過驗證的請求,連你的 Nginx 都碰不到。這篇會依序介紹概念、實戰設定三種情境(整個子網域、單一路徑、Webhook 開洞),再說明 Grafana 如何吃 Access 的 JWT 自動登入,最後附上驗證腳本與我認為最重要的常見陷阱。文中的網域一律使用範例:團隊名稱 myteam、網域 example.com、email [email protected]。

Cloudflare Access 是什麼

Cloudflare Access 是 Cloudflare Zero Trust(官方現在統稱 Cloudflare One)裡的一個產品,作用是替你的應用程式加上一層「身分驗證閘門」。每一個受保護的網址,都要先符合你定義的 Policy 才能通過。官方文件對 Access 的預設行為說得很清楚:「All Access applications are deny by default」,也就是沒有任何符合的 Allow policy,就一律拒絕。

在動手之前,先把幾個核心名詞弄清楚:

名詞 說明
Team name / 團隊網域 Zero Trust 組織的唯一識別名稱,Access 用它產生登入網域 myteam.cloudflareaccess.com,JWT 的 issuer 與公鑰網址都會用到
Application 要保護的對象。我們用的是 Self-hosted 類型的 public hostname application:服務有公開 DNS,使用者透過 Access 驗證後才能進入。官方文件列出的類型還有 SaaS、Private network、MCP servers 等
Policy 定義「誰能進」。每個 Policy 有一個 Action,以及 Include(OR)、Require(AND)、Exclude(NOT)三種規則
Identity Provider(IdP) 使用者怎麼證明身分:One-time PIN、Google、GitHub、Microsoft Entra ID、通用 OIDC / SAML 等
Service Token 給機器(CI、監控)用的帳號,由 Client ID 與 Client Secret 組成,搭配 Service Auth action
Access JWT 驗證通過後 Cloudflare 簽發的 token,會放在 Cf-Access-Jwt-Assertion header(瀏覽器也會帶 CF_Authorization cookie),讓你的 origin 可以再驗一次

Policy 的四種 Action

依照官方文件,Policy 的 Action 有四種,每個 Policy 只能有一種:

Action 行為 我的使用心得
Allow 符合條件的使用者可以進入應用程式 最常用,搭配 Include: Emails 就能限制只有自己能進
Block 符合條件者不能進入;官方建議搭配 Allow 用來挖例外 因為預設就是拒絕,我很少單獨使用
Bypass 關閉符合流量的 Access 驗證;官方說明 Bypass 不執行任何 Access 安全控制,請求也不會被記錄 等於完全放行,只在 Webhook 這種機器對機器的場景使用,而且一定要加 IP 限制
Service Auth 不需 IdP 登入的驗證流程,例如 Service Token 與 mutual TLS 延伸應用:讓 CI、監控等機器帳號存取

還有兩個我覺得值得記住的官方細節。第一,Bypass 與 Service Auth 的 Policy 會先執行,然後才輪到 Allow 與 Block,各自依列表由上往下比對,符合就停止。第二,Bypass 不能使用 Email 這類身分型 selector,所以我在 Webhook 的例子裡用的是 IP ranges。官方文件也特別警告:Allow policy 若使用 Include Everyone,或把 Login Methods 設成 One-time PIN,會讓任何人都能進入應用程式。後者很容易誤會,我會在後面的陷阱章節再提。

登入方式與方案

官方文件說明,新的 Zero Trust 組織預設使用 Cloudflare identity provider(以 Cloudflare 帳號登入);One-time PIN 不需要額外設定,只要把使用者的 email 加進 Policy,Cloudflare 就會寄 PIN 給他;另外也支援 Microsoft Entra ID、通用 OIDC 與 SAML,並可同時啟用多個 IdP,讓使用者自行挑選登入方式。至於 Google、GitHub 等其他登入方式,實際可選的項目請以 Zero Trust 設定畫面的 Identity providers 清單為準,我自己只使用 One-time PIN。

One-time PIN 的運作方式:在登入頁輸入 email,按下 Send login code,再輸入收到的 PIN。官方文件寫明 PIN 的有效期限為 10 分鐘,每組 PIN 只能使用一次,重新申請會讓前一組失效。而且 Cloudflare 只會寄信給「Policy 允許的 email」,不在名單內的人看到的是一樣的「已寄出」訊息,但實際上什麼都收不到,不會洩漏哪些 email 有權限。

費用方面,Cloudflare 的官方部落格公告 Free plan 可保護最多 50 位使用者、不需付費。個人用或小團隊拿來保護後台綽綽有餘;不過方案內容可能調整,請以 Cloudflare 當下的方案頁面為準。另外官方文件的帳號限制頁面列出:Access applications 預設上限 500 個、Service tokens 上限 50 個。

開始前的準備

正式設定之前,我先確認幾件事,避免做到一半才發現前提不成立:

  • 網域已接上 Cloudflare 並開啟 proxy:要保護的主機名稱必須是橘色雲朵(Proxied)。如果是 DNS only,流量根本不會經過 Cloudflare,Access 就管不到。
  • 建立 Zero Trust 組織:第一次進入 Zero Trust 時會要求設定 team name,這個名稱之後會變成 myteam.cloudflareaccess.com,建議取好記又不含敏感資訊的名稱。Free plan 同樣要走完這個步驟,但不會被收費。
  • 確認 Policy 要用的身分:我只允許自己,所以用 Include 的 Emails 規則放 [email protected];如果是團隊,建議改用 Email domain 或 IdP group。
  • 先想好 origin 怎麼防護:只要有人能繞過 Cloudflare 直連 origin IP,Access 就形同虛設。這點我放到「常見陷阱」詳談,但它不是最後才處理的事。
  • 留一條備援路徑:Access 設錯可能把自己鎖在門外。我會先保留 SSH 登入,並且不要在同一個 session 把所有 Policy 都改完才測試。

Dashboard 的路徑依官方文件為:Zero Trust > Access controls > Applications > Create new application > Self-hosted and private > Add public hostname。介面文案偶爾會改版,以下我一律用英文原名描述。

實戰一:整個子網域(Grafana)

第一個案例是最單純的:Grafana 整個子網域 grafana.example.com 都只讓我自己進去。

步驟

  • 進入 Applications,建立 Self-hosted and private 類型的應用程式,按 Add public hostname。
  • Domain 選擇 example.com 這個 zone,Subdomain 填 grafana。Path 留空代表整個主機名稱,官方文件說明留空或使用 example.com/* 會保護該網域底下的所有路徑。
  • 在 Access policies 新增一個 Policy:Action 選 Allow,Include 選 Emails,填入 [email protected]。
  • Identity providers 勾選 One-time PIN。若只啟用一個 IdP,可以開啟 Apply instant authentication,直接跳到該 IdP 的登入流程;我因為用 PIN,所以保留 Access 登入頁。
  • 設定 Session Duration。官方文件說明未設定時預設為 24 小時;後台工具我偏好保留預設,不特別拉長。
  • 儲存後,從無痕視窗開啟 https://grafana.example.com,應該會被導到 myteam.cloudflareaccess.com 的登入頁。輸入 email、按 Send login code、輸入信箱收到的 PIN,就會進入 Grafana。

用概念化的方式表示,這個 Application 與 Policy 大概長這樣:

# 概念示意,並非 Cloudflare 的實際設定檔格式
application:
  type: self-hosted (public hostname)
  hostname: grafana.example.com
  path: (empty)            # 整個主機名稱
  session_duration: 24h
  identity_providers: [One-time PIN]

policy:
  name: only-me
  action: Allow
  include:
    - emails: [[email protected]]

這時候我先不急著處理 Grafana 的登入。通過 Access 後,你會看到 Grafana 自己的登入頁,必須再輸入一次 Grafana 的帳密。這其實已經是兩道門:第一道 Access 驗 email,第二道 Grafana 驗帳密。等到後面「JWT 自動登入」章節,我們會把第二道改成自動帶入。

Jenkins 的設定幾乎一模一樣:Subdomain 改成 jenkins,Policy 同樣只允許自己的 email。差別在於 Jenkins 有 GitHub Webhook 需要從外部打進來,這是下面實戰三要解決的事。

實戰二:只保護某個路徑

第二個情境比較有趣。有些服務本身是公開的,例如 app.example.com 對外提供正常功能,但裡面附了一個除錯工具,路徑是 /debug-tool。後台與除錯工具不該對外公開,但整個服務又不能全部鎖起來。這時候就適合用路徑型 Application:服務其餘部分維持公開,只有 app.example.com/debug-tool 需要登入。

設定方式

建立 Application 時,Domain 填 app.example.com,Path 填 debug-tool。接著套用跟前面相同的 Allow 與 Emails Policy。這裡有幾個官方文件寫明的路徑規則,一定要先弄懂:

  • 路徑具有繼承關係:沒有自己規則的路徑,會繼承最近一層父路徑的規則;越具體的規則優先,且不會繼承更上層的規則。
  • 萬用字元不涵蓋父路徑:官方文件明確寫到,在 Path 欄位使用萬用字元「does not cover the parent path nor the apex domain」。也就是說 debug-tool/* 不會保護 /debug-tool 本身,要保護它與底下所有子路徑,必須分別設定,或者直接填 debug-tool 並實測子路徑是否被涵蓋。
  • 不支援 Query string 與 # 錨點:Application 的路徑不能包含 ?foo=bar;官方文件同時說明不支援 port number 與 #。
  • 每段最多一個萬用字元:子網域每個以點分隔的部分、路徑每個以斜線分隔的部分,最多各一個 *。

值得注意的是,官方的路徑文件並沒有說明大小寫是否敏感,也沒有說明結尾斜線怎麼處理。這種文件沒寫的地方,我不想靠猜,所以後面會用腳本實測。

我的實測結果

設定完成後,我對這個路徑做了一輪「各種寫法」的測試,結果如下(以下為我自己環境的實測,用 curl 觀察 HTTP status code):

測試的請求 結果
/debug-tool 302,導向登入頁
/Debug-Tool(大小寫不同) 302,導向登入頁
/debug-tool/(結尾加斜線) 302,導向登入頁
URL 編碼過的寫法 302,導向登入頁
/debug-tool 底下的 API,用 GET 302,導向登入頁
/debug-tool 底下的 API,用 POST 302,導向登入頁
同網域的其他頁面與 API 200,維持公開

這個結果符合預期:路徑被鎖住,其他地方不受影響。但我想強調,這是我特定環境與當下行為的實測,不是 Cloudflare 的保證。路徑比對的邊界情況(大小寫、編碼、特殊字元)在不同時間點可能有差異,所以我習慣把這支測試腳本留著,每次改 Policy 或 Cloudflare 更新後就重跑一次。腳本放在後面的「驗證與測試腳本」章節。

另外一個設計上的取捨:如果可以,我更傾向讓這種除錯工具直接搬到獨立的子網域(例如 debug.example.com)再整個鎖起來,這樣不用煩惱路徑比對的細節。但現實中服務已經上線、路徑不好搬時,路徑型 Application 就是個很實用的解法。

實戰三:Webhook 用 Bypass 開洞

Jenkins 整個子網域被 Access 擋住後,馬上遇到一個問題:GitHub 的 Webhook 是機器對機器的請求,不會也不可能走 One-time PIN 登入。如果不處理,每次 push 觸發的 webhook 都會拿到 302 導向,建置就不會被觸發。

常見的解法有幾種:Service Token(但這需要發送端能帶上自訂 header,webhook 發送端通常不方便)、改用 Jenkins 主動輪詢(浪費資源、延遲高)、或是只對 webhook 路徑開 Bypass。我選擇最後一種,但條件是雙重保護:

  • 第一層:Access Bypass policy 限定來源 IP:只對 webhook 路徑(Jenkins 的 GitHub plugin 預設是 /github-webhook/)建立一個 Application,Policy 的 Action 選 Bypass,Include 選 IP ranges,填入 GitHub 官方公布的 webhook IP 段。
  • 第二層:Jenkins 端驗證 webhook secret:就算有人偽造來源或 IP 段被濫用,沒有 secret 簽章的請求也無法觸發建置。

GitHub 的 IP 段不要手抄,用官方的 meta API 取得,其中 hooks 欄位就是 webhook 的來源範圍。IP 段會變動,建議定期重新取得並更新 Policy:

# 取得 GitHub webhook 的來源 IP 段(hooks 欄位)
curl -s https://api.github.com/meta | jq -r '.hooks[]'

# 只看 IPv4
curl -s https://api.github.com/meta | jq -r '.hooks[]' | grep -v ':'

設定重點

  • 建立新的 Application:Domain 為 jenkins.example.com,Path 為 github-webhook。這會比整個 jenkins.example.com 的 Application 更具體,依官方路徑規則,越具體的規則優先。
  • Policy 的 Action 選 Bypass,Include 選 IP ranges,貼上 hooks 的 IP 段。官方文件提到 Bypass 不能使用身分型 selector,IP ranges 則可以。
  • 在 Jenkins 的 GitHub plugin 與 GitHub repository 的 Webhook 設定中,填入相同的 secret,並確認 Jenkins 有啟用 secret 驗證。
  • 實測:從自己的電腦(非 GitHub IP)對 webhook 路徑送請求,應該仍然被 Access 擋下;從 GitHub 的 Webhook 頁面按 Redeliver,應該能回 200。

必須說清楚:Bypass 等於完全不驗證。官方文件寫得很直接,Bypass 不執行 Access 安全控制,請求也不會被記錄,並且不建議用 Bypass 授予內部應用程式長期的直接存取。所以這個洞要開得越小越好:路徑要精確、IP 要限縮、應用層一定要有 secret。三者缺一,我都不敢放。

GitHub 的 IP 清單還有一個現實問題:它會變。我的做法是把「抓 meta、比對 Policy 現有 IP」寫成一支每週跑一次的小腳本,發現差異先通知自己,再手動更新 Policy,避免哪天 GitHub 換了 IP 段,webhook 默默失效卻沒人發現。

Grafana 用 JWT 自動登入

通過 Access 之後還要再輸一次 Grafana 密碼,體驗其實很差。好消息是,Access 驗證成功後,Cloudflare 會在轉發給 origin 的請求加上 Cf-Access-Jwt-Assertion header,裡面是簽過名的 JWT,包含使用者的 email。Grafana 內建 JWT 驗證,可以讀這個 header、用 Cloudflare 公開的金鑰驗簽,再把 email 對應成 Grafana 使用者。這樣一來,通過 Access 就等於登入 Grafana,不用再輸入密碼。

JWT 的驗證流程

依照官方文件,驗證 Access JWT 的方式如下:

  • 公鑰網址是 https://myteam.cloudflareaccess.com/cdn-cgi/access/certs,也就是團隊網域加上 /cdn-cgi/access/certs。
  • 官方建議驗證 Cf-Access-Jwt-Assertion header,而不是 CF_Authorization cookie。
  • 用 RS256 驗簽,並檢查 iss 是否為你的團隊網域 https://myteam.cloudflareaccess.com,以及 aud 是否為該 Application 的 AUD tag。
  • Access 預設每 6 週輪替一次簽章金鑰,舊金鑰在輪替後 7 天內仍然有效。所以不要把公鑰寫死,要從 certs 網址動態抓取,並用 JWT 的 kid 對應。
  • AUD tag 可以在 Application 的設定頁(Additional settings)找到,除非刪除重建 Application,否則不會變。

這裡 aud 的檢查非常重要。團隊網域底下可能有很多個 Application,如果不檢查 aud,別的 Application 簽出來的 JWT 也可能被你的 Grafana 接受。

docker-compose 設定範例

Grafana 的設定可以用環境變數覆蓋,規則是 GF_<區段>_<key>。以下是我的 compose 設定(已改成範例值)。image 建議固定版本(例如下面的 12.4.11),不要用 latest,避免重新拉 image 時悄悄升級而讓設定行為改變:

services:
  grafana:
    image: grafana/grafana:12.4.11
    restart: unless-stopped
    environment:
      # 本機 admin 帳號保留作為備援(密碼放在 .env,不要寫進 compose)
      GF_SECURITY_ADMIN_USER: admin
      GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD}
      GF_SERVER_ROOT_URL: https://grafana.example.com
      GF_USERS_ALLOW_SIGN_UP: "false"

      # 信任 Cloudflare Access 簽發的 JWT,通過 Access 後直接登入
      GF_AUTH_JWT_ENABLED: "true"
      GF_AUTH_JWT_HEADER_NAME: Cf-Access-Jwt-Assertion
      GF_AUTH_JWT_EMAIL_CLAIM: email
      GF_AUTH_JWT_USERNAME_CLAIM: email
      GF_AUTH_JWT_JWK_SET_URL: https://myteam.cloudflareaccess.com/cdn-cgi/access/certs
      GF_AUTH_JWT_CACHE_TTL: 60m
      GF_AUTH_JWT_EXPECT_CLAIMS: '{"iss": "https://myteam.cloudflareaccess.com", "aud": ["<你的-Application-AUD-tag>"]}'
      GF_AUTH_JWT_AUTO_SIGN_UP: "true"
      GF_AUTH_JWT_ROLE_ATTRIBUTE_PATH: "email == '[email protected]' && 'Admin' || 'Viewer'"
    ports:
      - "127.0.0.1:3000:3000"

逐項說明這幾個設定的用途,以下對照 Grafana 官方 JWT 文件:

設定 用途
GF_AUTH_JWT_HEADER_NAME 指定從哪個 HTTP header 讀 token,這裡是 Access 的 Cf-Access-Jwt-Assertion
GF_AUTH_JWT_JWK_SET_URL 從 HTTPS 網址載入 JWKS,也就是 Cloudflare 的 certs 網址,Grafana 才能自動跟著金鑰輪替
GF_AUTH_JWT_CACHE_TTL JWKS 的快取時間,預設 60m;若 endpoint 回傳更短的 cache-control 則以較短者為準
GF_AUTH_JWT_EXPECT_CLAIMS 要求 token 的 claim 必須完全符合,這裡檢查 iss 與 aud
GF_AUTH_JWT_EMAIL_CLAIM / USERNAME_CLAIM 用哪個 claim 當 email 與使用者名稱,Access 的 JWT 有 email claim,直接使用
GF_AUTH_JWT_AUTO_SIGN_UP 找不到對應使用者時自動建立
GF_AUTH_JWT_ROLE_ATTRIBUTE_PATH 用 JMESPath 運算式決定角色,我讓自己的 email 是 Admin,其餘一律 Viewer

三個我實際踩過或特別在意的重點:

  • 一定要設 EXPECT_CLAIMS 的 aud:理由前面說過,避免同一個團隊網域下的其他 Application 簽的 JWT 被誤收。
  • 保留本機 admin 帳號當備援:萬一 Access 或 JWT 設定出問題,還可以從 origin 本機(例如 SSH tunnel 到 127.0.0.1:3000)用 admin 帳密登入修復。我也讓 Grafana 只綁在 127.0.0.1,由前面的反向代理對外。
  • 角色對應要保守:預設一律 Viewer,只有明確列出的 email 才是 Admin。官方文件說明若運算式沒有回傳有效角色,使用者會得到 auto_assign_org_role;想更嚴格可以搭配 role_attribute_strict。

設定完成、重啟 Grafana 之後,流程變成:打開 grafana.example.com → Access 驗證 email(PIN)→ 直接進入 Grafana 並以該 email 登入,不用再輸入任何 Grafana 密碼。

我的導入順序與檢查清單

實際導入時,我不是一口氣全部設定完,而是照風險由低到高分三輪進行,每一輪都先驗證再往下走。這個順序也是我建議你照做的,因為 Access 一旦設錯,最慘的結果是把自己鎖在外面,或是把不該公開的東西放出去,兩者都不好收拾。

  • 第一輪:新建、沒人依賴的入口。先拿 Grafana 這種只有自己使用的子網域練習,確認 PIN 能收到信、Policy 能擋、能放,熟悉 Dashboard 的操作之後再往下。
  • 第二輪:已經有流量的服務。像路徑型 Application,服務本身有真實使用者,所以要先在低峰時段設定,立刻跑測試腳本,確認「受保護路徑被擋、其他頁面仍是 200」,再觀察一陣子有沒有使用者回報異常。
  • 第三輪:有機器呼叫的入口。Jenkins 這類有 Webhook 的服務,必須先列出所有會從外部打進來的呼叫者(GitHub、監控、其他腳本),逐一決定它們走 Bypass、Service Token,或是乾脆不要對外。漏掉任何一個,就是半夜建置失敗才發現。

每次調整後,我會固定走過這份清單:

檢查項目 怎麼驗證
Application 的網域與路徑正確 無痕視窗開啟,確認被導向登入頁;用測試腳本逐一測試各種寫法
Policy 只允許預期的身分 用另一個非白名單的 email 嘗試登入,確認收不到 PIN、無法進入
未受保護的路徑沒有被誤傷 腳本中的「其他頁面」與 API 應維持 200
Bypass 的來源限制有效 從非 GitHub 的來源打 webhook 路徑,應被擋下
origin 無法被直連 從外部對 origin IP 直接發請求,應該連不上或被拒絕
備援路徑可用 確認 SSH 與本機 admin 帳號仍然可以登入

這份清單看起來囉嗦,但每一項都對應一個我認為真的會發生的失誤。尤其是「用另一個非白名單的 email 測試」這件事,很多人只用自己的帳號測,結果永遠都是成功,卻從來沒驗證過「沒有權限的人會被擋下」這個最核心的目標。

驗證與測試腳本

Access 的設定看起來都對,不代表真的有擋住。「改完了」不等於「驗過了」,我習慣用腳本把預期行為寫成清單,每次改 Policy 就跑一遍。下面是測試路徑型 Application 的範例:

#!/usr/bin/env bash
# 逐一測試受保護路徑的各種寫法,預期全部回 302(導向登入頁)
# 同網域的其他頁面與 API 預期回 200
BASE="https://app.example.com"

check() {
  local method="$1" path="$2" expect="$3"
  local code
  code=$(curl -s -o /dev/null -w '%{http_code}' -X "$method" "${BASE}${path}")
  if [ "$code" = "$expect" ]; then
    echo "PASS  $method $path -> $code"
  else
    echo "FAIL  $method $path -> $code (expect $expect)"
  fi
}

echo "== 受保護路徑,預期 302 =="
check GET  "/debug-tool"             302
check GET  "/Debug-Tool"             302   # 大小寫不同
check GET  "/debug-tool/"            302   # 結尾斜線
check GET  "/%64ebug-tool"           302   # URL 編碼(d)
check GET  "/debug%2Dtool"           302   # URL 編碼(-)
check GET  "/debug-tool/api/status"  302   # 底下的 API,GET
check POST "/debug-tool/api/run"     302   # 底下的 API,POST

echo "== 其他頁面,預期 200 =="
check GET  "/"                       200
check GET  "/api/health"             200

這支腳本的重點是用 -s -o /dev/null -w '%{http_code}' 只取狀態碼,不抓內容,也不跟隨轉址(沒有加 -L),所以看到 302 就代表被 Access 攔下並導向登入。想進一步確認導向的目標,可以加上 -w '%{redirect_url}',看到的網址應該在 myteam.cloudflareaccess.com。

Webhook 的 Bypass 也要測,而且要測「不該通過」的那一面:

# 從非 GitHub 的來源打 webhook 路徑,預期被 Access 擋下(302)
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://jenkins.example.com/github-webhook/

# 整個 Jenkins 首頁,預期 302
curl -s -o /dev/null -w '%{http_code}\n' https://jenkins.example.com/

# 檢查 Access 發出的 JWT 公鑰是否可正常取得
curl -s https://myteam.cloudflareaccess.com/cdn-cgi/access/certs | jq '.public_certs | length' 

最後一行是在確認 certs 端點可以連線。順帶一提,官方文件建議以 kid 比對 public_certs 裡的憑證;Grafana 的 JWKS 載入會幫你處理好,不需要自己解析。

常見陷阱

陷阱一:有人直接打 origin IP,Access 就被繞過

這是我認為最重要的一點。Access 只保護「經過 Cloudflare」的流量。如果你的 origin IP 被知道了(例如歷史 DNS 紀錄、憑證透明度紀錄、郵件標頭、掃描器),攻擊者可以直接對該 IP 發請求,完全不經過 Cloudflare,自然也沒有 Access。官方的 Authenticated Origin Pulls 文件也提到同樣的風險:沒有額外防護時,任何發現 origin IP 的人都可以直接送請求,跳過 Cloudflare 的保護。

防線要放在 origin,有兩條路,建議兩條都做:

  • 防火牆只放行 Cloudflare 的 IP 段:Cloudflare 公布的 IP 清單在 https://www.cloudflare.com/ips-v4 與 ips-v6。80、443 只允許這些來源,其餘一律丟棄。
  • 應用程式端驗證 Access JWT:如同 Grafana 的設定,origin 自己驗簽、驗 iss 與 aud,沒有有效 JWT 就拒絕。即使有人繞過 Cloudflare,也無法偽造簽章。
  • 另外可評估 Authenticated Origin Pulls(mutual TLS,讓 origin 驗證請求確實來自 Cloudflare)。官方文件指出它不適用於 Off 與 Flexible 加密模式,也與 Cloudflare Tunnel 不相容。如果用 Tunnel,則改由 Tunnel 的架構避免對外暴露 origin。
# 範例:用 ufw 只放行 Cloudflare IP 段到 443(80 同理)
# 清單請定期更新,不要寫死
for ip in $(curl -s https://www.cloudflare.com/ips-v4); do
  sudo ufw allow from "$ip" to any port 443 proto tcp
done
for ip in $(curl -s https://www.cloudflare.com/ips-v6); do
  sudo ufw allow from "$ip" to any port 443 proto tcp
done
# 最後確認預設拒絕進站
sudo ufw default deny incoming

這段只是示意,實際操作防火牆前請先確認 SSH 的規則,避免把自己鎖在門外。另外有兩點一定要注意:

  • 既有的「允許 443 from anywhere」規則要先刪掉(例如 ufw delete allow 443/tcp,或用 ufw status numbered 找出編號再刪),否則那條規則仍然放行所有來源,Cloudflare 白名單等於沒作用。
  • Docker 對外 publish 的 port 會繞過 ufw:Docker 自己寫 iptables 規則,ufw 擋不到 -p 443:443 這類對外發佈的容器 port。解法是把限制寫在 DOCKER-USER chain,或是容器只綁 127.0.0.1(如前面的 Grafana 範例),再由主機上的反向代理對外。

陷阱二:Bypass 等於完全不驗證

前面提過,這裡再強調一次。Bypass 不執行 Access 的任何安全控制,而且請求不會被記錄。所以 Bypass 一定要搭配兩件事:IP 限制,以及應用層的 secret 或簽章驗證。另外官方文件還有一個提醒:使用 device posture 的 Bypass policy,在啟用 Zaraz 或有 Worker 攔截請求時不會生效。如果你的場景符合,要改用 Service Auth。

陷阱三:Policy 設成「任何 One-time PIN 使用者」

官方文件有一句警告,我認為值得獨立成一條:Allow policy 若使用 Include Everyone,或把 Login Methods 設成 One-time PIN,會讓任何人都能進入應用程式。因為任何人都可以用自己的 email 申請 PIN,只要 Policy 沒有限制 email,就等於沒鎖。正確做法是用 Emails、Emails ending in 或 IdP group 明確限定身分,而不是只限定「登入方式」。

陷阱四:Session 時長與登出

官方文件說明,Application domain 的 cookie 會先用 Policy 的 session duration,其次是 Application 的設定,最後預設 24 小時;團隊網域的 cookie 也是類似規則。這代表:一旦通過驗證,24 小時內同一個瀏覽器不會再被要求登入。對自己常用的裝置沒問題,但如果你在公用電腦登入後忘了登出,這 24 小時就是風險期。後台工具我會視重要程度縮短 session,並且避免在不信任的裝置上登入。官方文件的 cookie 頁面只說明各 cookie 的有效期限,並未詳述登出行為,登出機制請以實際測試與官方說明為準。

陷阱五:API 與機器呼叫

被 Access 保護的路徑,沒有有效身分的請求會被攔下,我實測的結果是 302 導向登入頁。對瀏覽器沒問題,但對程式的 HTTP client 來說,302 會讓它們以為拿到一個奇怪的 HTML 頁面,而不是明確的錯誤。如果有 CI、監控、腳本需要呼叫受保護的 API,就需要換個方式。

這是 Service Token 的用途(以下為延伸應用說明,我自己的環境目前沒有使用)。依官方文件:

  • Service Token 由 Client ID 與 Client Secret 組成,client 在每個請求帶上 CF-Access-Client-Id 與 CF-Access-Client-Secret 兩個 header。
  • Access Policy 必須使用 Service Auth action,否則 Access 仍然會要求 IdP 登入。
  • Client Secret 只會在建立時顯示一次,遺失就得重建;Token 有到期日,可以用 Refresh 延長一年,也可以輪替 secret 並享有寬限期。
  • 組織層級有 Strict service token authentication 的選項,讓驗證失敗的 Token 請求回傳 401 或 403,而不是導向登入。依官方文件,2026-10-05(含)之後建立的 Zero Trust 組織預設開啟此設定且無法關閉;更早建立的組織可自行開關。
# 延伸範例:CI 或監控用 Service Token 存取受保護的 API
curl -s https://app.example.com/debug-tool/api/status \
  -H "CF-Access-Client-Id: ${CF_ACCESS_CLIENT_ID}" \
  -H "CF-Access-Client-Secret: ${CF_ACCESS_CLIENT_SECRET}"

金鑰請放進 CI 的 secret 管理,不要寫進程式碼或 compose 檔。

陷阱六:路徑型 Application 的邊界

回到前面的實戰二,再補幾個容易忽略的地方:路徑型 Application 要靠 Access 對 URL 的比對,而你的 origin 對 URL 的解讀可能不同(大小寫、編碼、正規化)。兩邊解讀不一致,就是繞過的空間。所以我的原則是:能整個子網域鎖,就不要用路徑鎖;一定要用路徑鎖,就用腳本把各種寫法測過,並且在應用程式端也加上驗證當第二道防線。官方文件也提到,路徑不支援 query string,所以不要期待用 ?token= 這類條件區分。

陷阱七:第一次設定的 Application 順序

官方在 Tunnel 的說明中特別提醒:要先建立 Access application,再發佈應用程式,否則在 Application 建立之前,發佈出去的服務是任何人都能存取的。放到 proxy 後面的情境道理相同:新增子網域時,順序應該是先把 Application 與 Policy 建好,再把 DNS 記錄指向 origin,不要反過來。

小結

回頭看這整套做法,Cloudflare Access 替我解決了「後台工具該怎麼保護」這個老問題:

  • 整個子網域(Grafana、Jenkins):一個 Allow policy 加上 Emails 限制,不用維護 VPN。
  • 單一路徑(除錯工具):服務維持公開,只有後台路徑要登入,並用腳本實測各種寫法。
  • Webhook:只對該路徑開 Bypass,限定 GitHub 官方 IP 段,再加上 Jenkins 的 webhook secret 雙重保護。
  • Grafana JWT 自動登入:通過 Access 後免輸入密碼,並保留本機 admin 當備援。
  • 機器帳號:需要時可以用 Service Token 搭配 Service Auth(延伸應用)。

但請記住最重要的一件事:Access 是一道「邊緣」的閘門,不是整間房子的牆。origin 防火牆只放行 Cloudflare IP、應用層驗證 JWT、Bypass 搭配 IP 與 secret,這三件事沒做好,前面設定得再漂亮都可能被繞過。建議你設定完之後,也像我一樣寫一支測試腳本,把「該擋的有擋、該放的有放」變成可以重複執行的檢查。

最後提醒,本文的規格與限制都整理自官方文件,Cloudflare 的介面與方案會不定期調整,動手前請再對照一次最新文件。

參考資料

https://developers.cloudflare.com/cloudflare-one/policies/access/

https://developers.cloudflare.com/cloudflare-one/applications/configure-apps/self-hosted-public-app/

https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/

https://developers.cloudflare.com/cloudflare-one/access-controls/policies/app-paths/

https://developers.cloudflare.com/cloudflare-one/identity/idp-integration/

https://developers.cloudflare.com/cloudflare-one/identity/one-time-pin/

https://developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/

https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/

https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/

https://developers.cloudflare.com/cloudflare-one/account-limits/

https://developers.cloudflare.com/cloudflare-one/setup/

https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/

https://www.cloudflare.com/ips/

https://blog.cloudflare.com/teams-plans/

https://docs.github.com/en/rest/meta/meta

https://grafana.com/docs/grafana/latest/setup-grafana/configure-access/configure-authentication/jwt/

系列文章:自架 VM 維運實戰

保護 → 部署 → 觀測 → 調校,建議依序閱讀:

Comments

No comments yet.

請先登入