前言
自己養一台 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-Assertionheader,而不是CF_Authorizationcookie。 - 用 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-USERchain,或是容器只綁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/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 維運實戰
保護 → 部署 → 觀測 → 調校,建議依序閱讀:
- 第 1 篇:【資安】Cloudflare Access 實戰:不用 VPN 保護後台與 Grafana(本篇)
- 第 2 篇:【CI/CD】Jenkins 打造安全的 Laravel 部署:JCasC 與受限 SSH
- 第 3 篇:【監控】Prometheus + Grafana 監控 Docker 服務與 Slack 告警
- 第 4 篇:【監控】Loki + Alloy 集中 Laravel 與 Nginx Log:遮罩與踩坑
- 第 5 篇:【效能】k6 壓測 Laravel Octane:找出 2 核 VM 的真實上限
No comments yet.