Cloudflare Zero Trust 保護 Ghost 後台時,怎麼讓 API 發文不被擋?

把 Ghost admin 區放進 Cloudflare Access 之後,API 發文卻一直 302 到登入頁?問題不在 token,在 policy 的 Action 設成 Allow 而不是 Service Auth。一篇紀錄症狀、原因與最終的客戶端寫法。

分享
Silver padlock on a metal gate symbolizing access control and security
Photo by Muhammad Zaqy Al Fattah on Unsplash

把 Ghost 部署到公網之後,/ghost 後台變成漏洞掃描器最愛拜訪的路徑之一。最直覺的防線是把整段 admin 區放進 Cloudflare Zero Trust(Access),要求登入過 IdP 才放行。問題是——同一條 policy 也會把你拿來自動發文的 API request 一起擋掉。

這篇紀錄一次完整踩雷流程:症狀、錯誤的設定方式、為什麼錯,以及一行就能修好的正解。

TL;DR — 最大踩雷點

Service Token 加進 policy 的 Include 之後,Action 必須選 Service Auth,不能用預設的 Allow

Allow 要求 request 先通過 IdP 登入;程式呼叫沒有瀏覽器 session,永遠卡在第一步,token 那關根本不會被檢查到。改成 Service Auth(API 欄位 decision: "non_identity"),CF 才會跳過 IdP、直接驗 service token header。

症狀:API 呼叫拿到 HTML 不是 JSON

一切跡象都很「Cloudflare」。原本好好的 admin API 突然回不來 JSON:

$ curl -X GET https://example.com/ghost/api/admin/site/     -H "Authorization: Ghost <jwt>"
HTTP/2 302
location: https://<team>.cloudflareaccess.com/cdn-cgi/access/login/...

302 把你導去 Cloudflare 登入頁,回應 body 是 HTML。Python client 解析 JSON 就炸了:

json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

這代表 request 根本沒進到 Ghost,CF Access 在邊緣就把它攔了。

正解:Service Token,而不是放白名單 IP

很多人第一直覺是「找一條 bypass IP」或「在 WAF 加 allow list」。這兩條路在動態 IP、CI runner、雲函式環境都會壞。Cloudflare 設計上給程式呼叫用的鑰匙叫 Service Token

產生方式:

  1. Cloudflare Zero Trust → Access → Service Auth → Service Tokens
  2. Create Service Token,給它一個有意義的名字(哪台機器、哪個服務)
  3. 記下 Client IDClient SecretSecret 只顯示這一次

之後每次發 API request,多帶兩個 header:

CF-Access-Client-Id: <client_id>.access
CF-Access-Client-Secret: <client_secret>

把這兩個值放進專案的 .env、用 dotenv 載入,絕對不要 commit 進 repo。

踩雷點:Policy Action 只設 Allow 一定不會過,必須改成 Service Auth

把 token 加進 policy 的 Include 欄位後,預設 Action 是 Allow。看起來很合邏輯:「允許帶這顆 token 的 request」。但測下去仍然 302。

把 CF 回應的 redirect URL 解一下 query string 裡的 meta JWT,會看到關鍵欄位:

{
  "service_token_status": false,
  "auth_status": "NONE"
}

翻 CF 文件後才搞懂 decision 的四種值不能混為一談:

API 值UI 名稱意義
allowAllow必須先過 IdP 登入,再對 include 做匹配
denyBlock直接擋
bypassBypass完全不檢查
non_identityService Auth不需 IdP,純非身份條件匹配(service token、mTLS、IP)

程式呼叫沒有瀏覽器 cookie,也沒辦法走 IdP 登入流程。設成 Allow 等於告訴 Cloudflare:「先請使用者登入,登入完再看 token」。流程永遠卡在第一步,token 那關根本沒被檢查到——所以 service_token_status 永遠是 false

正解是把該條 policy 的 Action 改成 Service Auth(API 欄位是 decision: "non_identity")。修完之後 CF 就會直接拿 header 裡的 token 來比對,過了就放行進到 origin。

深入:CF-Access-Client-Id / Secret 是怎麼被驗證的

修好 policy 後 API 通了,但中間到底發生什麼事值得拆開看。理解流程,下次遇到別的詭異 302 才有方向。

你的程式
  │
  │ HTTPS request with headers:
  │   Authorization: Ghost <jwt-for-ghost>
  │   CF-Access-Client-Id: <client_id>.access
  │   CF-Access-Client-Secret: <client_secret>
  │   User-Agent: my-app/1.0
  ▼
Cloudflare edge (距你最近的 PoP)
  │
  │ 1. TLS 解密 → 看到明文 headers
  │ 2. 比對 Host + Path → 命中 Access Application
  │ 3. 取出 CF-Access-Client-Id / Secret
  │ 4. 查 Service Tokens DB:
  │    - client_id 存在?
  │    - client_secret 用 constant-time compare 比對 hash?
  │    - 沒過期 (expires_at)?
  │ 5. 通過 → 依 precedence 跑 policies:
  │      若 policy.decision == "non_identity"
  │        且 token_id 在 policy.include 中:
  │          → MATCH,准進 origin
  │      若 policy.decision == "allow":
  │          → SKIP(要求 IdP identity,此 request 沒有)
  │ 6. 簽一個短效 CF Access JWT 塞進 header:
  │      cf-access-jwt-assertion: eyJ...
  │ 7. 把整個 request 轉發給 origin
  ▼
Origin (Ghost)
  │
  │ Ghost 不認得 CF-Access-* headers,忽略它們
  │ 用 Authorization: Ghost <jwt> 走自己的 admin auth
  ▼
Ghost 回 JSON

幾個關鍵性質

1. 純 shared secret,沒有 HMAC 簽章。不像 AWS SigV4 或 GitHub Webhook 那種把 body 跟 timestamp 一起簽進 header。CF 的設計是「token = bearer credential」:拿到 secret 等於拿到通行證。安全完全靠兩件事:TLS 把明文藏在 client 與 CF edge 之間,加上 secret 本身的熵(64 hex chars = 256 bit)。

2. Replay 防禦靠 TLS,不靠 nonce。request 裡沒有 timestamp 或 nonce 欄位。中間人若能解 TLS 就能 replay。所以 secret 絕不能透過 HTTP、log 或錯誤回應出去。

3. Validation 完全在 edge,origin 看不到 secret。CF edge 比對完 token 後會把 CF-Access-Client-Secret header 留下(Ghost 收得到,但會忽略)。即使 origin 端漏 log 整包 request,secret 在 header 不在 body,比較難跟著 access log 一起出去——但仍然要當作敏感資料處理。

4. 兩層認證並存、互不取代。

  • CF-Access-Client-Id / Secret 證明「這台機器有資格穿過 CF Access policy」
  • Authorization: Ghost <jwt> 證明「這個呼叫者有 Ghost admin 權限」

CF 不關心 Ghost 那條,Ghost 不關心 CF 那條。少任何一條 request 就會在不同層失敗。

5. token 結構。

  • client_id 格式:<32-char-hex>.access,可視為 username
  • client_secret:64 hex chars,等同密碼,洩漏即等於 token 被偷走
  • Cloudflare 沒有「rotate without downtime」機制——要換 token 就是新增一顆、把它加進 policy、舊的刪掉,沒有原地更新

6. 失敗時行為很反直覺。token 沒帶或帶錯,CF 不回 401,而是 302 redirect 到 IdP 登入頁——因為它的預設使用者是瀏覽器。對程式而言這就是要去解 Location 裡的 meta JWT,看 service_token_status 是不是 false 來判斷是 token 沒對到,或根本沒走到 token 驗證那一步。

為什麼用兩個 header,不直接 Authorization Bearer?

歷史相容性。Authorization header 在 reverse proxy 鏈中很容易被 strip、覆寫,或被 origin 自己佔用——Ghost admin API 本身就吃 Authorization: Ghost <jwt>。CF 用兩個 vendor-prefix 自訂 header 避免衝突,client 可以一次帶 CF token 與 origin token,兩條認證互不踩腳,proxy 鏈裡誰要 strip 哪一條也比較清楚。

客戶端還有一個隱藏關卡:WAF Browser Integrity Check

policy 改對之後,再跑一次 Python 腳本,結果換成另一個錯:

HTTP 403: error code: 1010

這不是 Access 在擋,是 Cloudflare WAF。Error 1010 = browser signature 被 ban。原因是 Python 預設的 User-Agent 字串是 Python-urllib/3.x,正好踩在 WAF 的 bot heuristic 上。

修法極簡——給 HTTP client 設一個有辨識度但不像爬蟲的 User-Agent:

req.add_header("User-Agent", "my-app-name/1.0")

不必偽裝成 Chrome,自報家門更好查 log。

客戶端最終樣貌

把上面三件事整合到 API client 裡。以 Python urllib 為例:

import os, urllib.request

class GhostClient:
    def __init__(self):
        self.cf_id = os.environ.get("CF_ACCESS_CLIENT_ID", "")
        self.cf_secret = os.environ.get("CF_ACCESS_CLIENT_SECRET", "")

    def _headers(self, base):
        h = dict(base)
        h["User-Agent"] = "my-app/1.0"
        if self.cf_id and self.cf_secret:
            h["CF-Access-Client-Id"] = self.cf_id
            h["CF-Access-Client-Secret"] = self.cf_secret
        return h

    def request(self, url, method="GET", body=None, auth_header=None):
        headers = self._headers({"Authorization": auth_header} if auth_header else {})
        req = urllib.request.Request(url, data=body, headers=headers, method=method)
        with urllib.request.urlopen(req, timeout=30) as r:
            return r.read()

關鍵設計:當兩個 CF 環境變數都有設才注入 header。這樣同一份 client 拿去打沒套 Zero Trust 的環境,不會多送無用 header。

驗收清單

遇到 302 / 403 時,照這個順序自查:

  1. 解 redirect URL 的 meta JWT:看 service_token_status。是 false → policy 沒匹配到 token
  2. 檢查 policy decision:UI 顯示 Allow → 改成 Service Auth
  3. 檢查 Application 的 Path 設定:保護整個 host 留空;只想保護 /ghost 就明確寫入
  4. 檢查 User-Agent:不是 Python-urllib、不是 curl/x.x 也好過預設值
  5. 檢查 token 沒過期:Service Token 預設一年有效期,到期不會自動續

小結

Cloudflare Zero Trust 保護 Ghost 後台是值得做的安全升級,代價就是要懂得區分「人類登入」與「機器發 request」這兩條完全不同的驗證流程。把 policy 的 Action 從 Allow 換成 Service Auth,再給 HTTP client 補上 service token header 與一個正常的 User-Agent,整套自動化發文流程就回到能用的狀態。

下次看到 service_token_status: false,先想 decision 不是 non_identity,省下三小時翻 CF 文件的時間。