Cloudflare Workers 踩坑:儀表板編輯器部署會洗掉 Secret——401 鬼打牆實錄與解法

一個「client 帶 Bearer token、Worker 寫 R2」的簡單端點,卻因為 Cloudflare 儀表板的版本快照機制連環 401。完整還原除錯過程、診斷技巧、三種解法,以及要讓 AI 代操作時該先講清楚的七件事。

這篇在講什麼

我做了一個很單純的回報端點:桌面工具偵測到「關注軟體」時,把命中清單 POST 到 Cloudflare Worker,Worker 驗過 Bearer token 後把 JSON 寫進 R2。架構簡單到不行,結果卡了一整個下午——不管怎麼設定 token,永遠 HTTP 401

前情提要:這顆 Worker 的定位、適用場景與基本設定,寫在前一篇[《Cloudflare Workers 踩坑筆記:適用場景、設定,以及怎麼安全地結合 R2》](http://192.168.50.190/wordpress/2026/07/22/cloudflare-workers-%e8%b8%a9%e5%9d%91%e7%ad%86%e8%a8%98%ef%bc%9a%e9%81%a9%e7%94%a8%e5%a0%b4%e6%99%af%e3%80%81%e8%a8%ad%e5%ae%9a%ef%bc%8c%e4%bb%a5%e5%8f%8a%e6%80%8e%e9%ba%bc%e5%ae%89%e5%85%a8%e5%9c%b0/)。那篇建議把共享 token 放進環境 Secret——本篇就是那個建議在真實世界爆炸的完整過程,以及修正後的結論。

最後抓到的元兇完全出乎意料:Cloudflare 儀表板的「線上編輯器」部署程式碼時,會把「設定頁」後來新增或輪換的 Secret 繫結整個洗掉。你在設定頁補好 secret、回編輯器貼一版程式碼,secret 就歸零;再補、再貼、再歸零,鬼打牆就是這樣打起來的。

這篇完整記錄:端點怎麼設計、Worker 怎麼設定、401 怎麼一步步查到真相、三種解法怎麼選,以及——如果你想叫 AI 幫你弄這套,事先要提醒它哪些事才不會重演我的下午。

架構設計

需求:教學電腦上的清理工具掃到特定軟體(例如版本過舊的壓縮軟體)時,回報到集中端點,管理者事後查閱。

桌面工具 client                Cloudflare Worker              R2 bucket
──────────────                ─────────────────              ─────────
POST JSON            ──►      驗 Bearer token       ──►      classroom/<主機名>-<時間戳>.json
Authorization:                補 received_at、
Bearer <共享token>             來源 IP(cf-connecting-ip)
Code language: HTML, XML (xml)

安全設計的重點只有一條:R2 的 Access Key 永遠不出 Worker。client 只拿到「網址+一個只能寫入的共享 token」,token 外洩最多被灌垃圾資料,拿不到 bucket 的讀/刪權限,要止血只需換掉 token。

Worker 設定步驟

1. 建立 Worker 與 R2 bucket

  1. Cloudflare 儀表板 → Workers 和 Pages → 建立 Worker(取名如 my-report)
  2. R2 物件儲存 → 建立 bucket(取名如 reports)
  3. Worker → 繫結 → 新增繫結 → R2 貯體,變數名稱取 CLASSROOM_BUCKET,指向剛建的 bucket

2. Worker 程式碼(最終版,可直接抄)

// 共享上傳 token:與 client 內建值一致。
// 產生方式:openssl rand -hex 24(48 字元十六進位)
// 直接寫在程式碼裡而不用 env secret 的原因,見下方踩坑段落。
const UPLOAD_TOKEN = "<你的48字元隨機token>";

export default {
  async fetch(request, env) {
    if (request.method !== "POST") {
      return new Response("Method Not Allowed", { status: 405 });
    }
    const got = (request.headers.get("authorization") || "").trim();
    if (got !== "Bearer " + UPLOAD_TOKEN) {
      return new Response("Unauthorized", { status: 401 });
    }
    let payload;
    try {
      payload = await request.json();
    } catch {
      return new Response("Bad Request", { status: 400 });
    }
    // 來源 IP 由 Worker 這邊取,client 不用自己抓
    const record = {
      received_at: new Date().toISOString(),
      source_ip: request.headers.get("cf-connecting-ip") || "unknown",
      ...payload,
    };
    const machine = String(payload.machine_id || "unknown").replace(/[^a-zA-Z0-9_-]/g, "_");
    const key = `classroom/${machine}-${Date.now()}.json`;
    await env.CLASSROOM_BUCKET.put(key, JSON.stringify(record, null, 2), {
      httpMetadata: { contentType: "application/json" },
    });
    return new Response(JSON.stringify({ ok: true, key }), {
      status: 200,
      headers: { "content-type": "application/json" },
    });
  },
};
Code language: JavaScript (javascript)

3. client 端

任何語言都行,重點只有三件事:

  • POSTcontent-type: application/json
  • Authorization: Bearer <同一個token>
  • 失敗不要影響主流程(回報是輔助功能)

4. 驗證(整套修完就靠這個)

# 正確 token → 預期 200 {"ok":true,...}
curl -sS -w "\nHTTP %{http_code}\n" -X POST "https://<你的worker>.workers.dev/" \
  -H "content-type: application/json" \
  -H "authorization: Bearer <你的token>" \
  -d '{"machine_id":"test","matches":[]}'

# 錯誤 token → 預期 401
curl -sS -o /dev/null -w "HTTP %{http_code}\n" -X POST "https://<你的worker>.workers.dev/" \
  -H "authorization: Bearer wrong" -d '{}'

# GET → 預期 405
curl -sS -o /dev/null -w "HTTP %{http_code}\n" "https://<你的worker>.workers.dev/"
Code language: PHP (php)

三條全過才算通。只測正向路徑等於沒測。

踩坑實錄:401 鬼打牆

症狀

原始設計是把 token 放在 Worker 的環境 Secret(env.UPLOAD_TOKEN),程式碼這樣比對:

if (request.headers.get("authorization") !== `Bearer ${env.UPLOAD_TOKEN}`) {
  return new Response("Unauthorized", { status: 401 });
}
Code language: JavaScript (javascript)

client 端 401。curl 手打同一個 token,也是 401。在設定頁「輪換」secret 重貼一次,還是 401。

第一輪猜測:貼上夾了換行?

從聊天視窗/編輯器複製 token 時,尾端很容易帶一個換行,而 Cloudflare 的 secret 不會自動 trim,Bearer abc\n 永遠不等於 Bearer abc。於是把比對改成兩邊都 trim:

const expected = "Bearer " + String(env.UPLOAD_TOKEN || "").trim();
const got = (request.headers.get("authorization") || "").trim();
if (!env.UPLOAD_TOKEN || got !== expected) { ... }
Code language: JavaScript (javascript)

部署,重測——還是 401。這一步排除了空白問題,但也代表問題比想像中深。

關鍵一步:別再猜,直接讓 Worker 自己招

加一個暫時的診斷分支,GET 時回傳 secret 的長度和 SHA-256(不洩漏值本身,可以放心在正式環境短暫使用):

if (request.method === "GET") {
  const raw = String(env.UPLOAD_TOKEN ?? "");
  const data = new TextEncoder().encode(raw.trim());
  const digest = await crypto.subtle.digest("SHA-256", data);
  const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
  return new Response(
    JSON.stringify({ len_raw: raw.length, len_trimmed: raw.trim().length, sha256_trimmed: hex }),
    { headers: { "content-type": "application/json" } },
  );
}
Code language: JavaScript (javascript)

本機算出正確 token 的雜湊來對:

printf '%s' "<你的token>" | sha256sum
Code language: JavaScript (javascript)

結果一翻兩瞪眼:

{"len_raw":0,"len_trimmed":0,"sha256_trimmed":"e3b0c442..."}
Code language: JSON / JSON with Comments (json)

len_raw: 0——env.UPLOAD_TOKEN 根本是空的(e3b0c442... 是空字串的 SHA-256,看到這串直接可以背起來)。不是 token 貼錯,是 secret 整個不存在於線上版本。

真相:儀表板編輯器部署會洗掉 Secret

對照「部署」頁的版本歷程記錄,時間線長這樣:

版本動作 Secret 狀態
設定頁:新增祕密 UPLOAD_TOKEN ✅ 有
編輯器:貼程式碼 → 已手動部署 被洗掉
設定頁:輪換祕密(以為修好了) ✅ 有
編輯器:再貼程式碼 → 已手動部署 又被洗掉

機制:Workers 的每個「版本」都是程式碼+繫結設定的完整快照。線上編輯器(內嵌 VS Code)部署時,用的是編輯器載入當下的繫結快照——如果你的編輯器分頁是在 secret 設好之前開的,它部署出來的新版本就不含那個 secret。設定頁看起來還顯示「值已加密」(它顯示的是自己那條版本線),但實際上線的版本裡什麼都沒有

兩邊的 UI 各自呈現自己認知的狀態,沒有任何警告。這就是鬼打牆的完整成因:每次在設定頁補 secret 都真的補上了,每次回編輯器部署程式碼又都真的把它蓋掉了。

三種解法

  1. 順序紀律:所有程式碼修改先做完,secret 的新增/輪換永遠當最後一步(從設定頁部署會以「目前線上程式碼+新 secret」建版,不會反過來洗掉程式碼)。另外編輯器分頁用完就關,每次要改 code 重新開,確保它載到最新繫結。缺點:靠人的紀律,遲早再犯。
  2. 改用 wrangler:wrangler deploy + wrangler secret put 的行為是明確的,不會有快照錯亂。缺點:要裝工具、要 OAuth 登入,臨時修個東西時反而是額外阻力(我這次 wrangler OAuth 流程也另外踩了 callback 佔埠的坑,又是另一個故事)。
  3. token 直接寫進 Worker 程式碼(本案最終採用):

什麼時候不能這樣做:token 真的是機密(API 金鑰、DB 密碼)、會進版本控制公開 repo、或多人共用帳號看得到程式碼但不該看到值——這些情況乖乖用 secret + 順序紀律或 wrangler。

如果要 AI 幫你做,先提醒它這些

這次整段除錯我是和 AI(Claude Code)協作完成的。事後回顧,如果一開始就把下面這些話寫進提示詞,至少省一半時間。你可以直接抄這段給你的 AI:

1. **你可以直接 curl 測試端點**,401/200 當場見真章,不要用「應該可以了」回報我;修好的定義是正向+反向測試全過。
2. **Cloudflare 儀表板的線上程式碼編輯器你操作不了**(內嵌 VS Code,瀏覽器自動化的點擊和鍵盤事件打不進去)。需要改 Worker 程式碼時,直接給我完整檔案,我人工貼上部署,你負責驗證。
3. **Secret 的值不要叫我一個字一個字打**,也不要你來貼(安全規範你本來就不能碰憑證欄位)。流程設計成:你開好欄位→我貼→你驗證結果。
4. **懷疑 secret 有問題時,先加診斷分支拿證據**(回傳長度+雜湊,不洩漏值),不要在「可能貼錯了」上面繞圈。空字串的 SHA-256 是 e3b0c442...,看到它就是根本沒設值。
5. **注意 Cloudflare 的版本快照陷阱**:從線上編輯器部署會用編輯器載入當下的繫結快照,可能洗掉設定頁後來加的 secret。部署順序永遠是「程式碼先、secret 最後」,而且改完要用版本歷程記錄對時間線。
6. **wrangler login 是互動式 OAuth**,在你的 shell 裡跑很容易 callback 斷掉或多個程序搶 8976 埠。要用 wrangler 就讓我在自己的終端機登好再說,不然就換路。
7. 卡住兩三次就停下來換方法,不要對同一面牆重複衝刺。

反過來說,AI 在這次幫上大忙的地方也值得記:它可以無限耐心地 curl、比對雜湊、翻版本歷程找時間線矛盾——這些機械性驗證交給它,人只負責貼上和按部署,分工其實非常舒服。

總結

  • Cloudflare Workers 的「版本」是程式碼+繫結的完整快照;線上編輯器部署可能用舊快照洗掉新 secret,而且 UI 不會警告你。
  • 除錯憑證問題的正解是讓伺服器端自己報告狀態(長度+雜湊),一發診斷請求勝過十次盲猜重貼。
  • token 放 secret 還是寫進程式碼,取決於它是不是真的機密——已經隨 client 散布的共享 token,寫進程式碼反而消滅了一整類部署事故。
  • 和 AI 協作處理雲端設定時,把「它做不到的事」(操作內嵌編輯器、碰憑證)和「它最擅長的事」(API 驗證、證據蒐集)先講清楚,效率天差地遠。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *