跳到內容

安全性與多租戶隔離

查證日期
驗證環境wrangler@4.118.0·compatibility_date: 2026-07-24

第 27 章講了怎麼驗證身分,第 33 章講了怎麼跑別人的程式碼。這一章把整個系列散落的邊界收攏成一份可以照著檢查的東西 —— 並且用實測告訴你,哪些邊界是平台幫你守的,哪些是你以為有其實沒有的。


先講清楚你不用煩惱的部分(官方 security model 頁):

“V8 executes code inside isolates, which prevent that code from accessing memory outside the isolate — even within the same process.”

Spectre 緩解的具體手段:

  • Date.now() 回傳的是「上一次 I/O 的時間」,執行程式碼期間不會前進。(第 39 章那個「span 顯示 0 ms」就是這個的副作用。)
  • 不允許多執行緒與共享記憶體。
  • 動態行程隔離:runtime 偵測到效能指標可疑的 Worker,會把它排進自己的行程。
  • 每天重啟 runtime 打亂記憶體佈局。
  • 信任分層:「A customer who signs up for the Free plan will not be scheduled in the same process as an Enterprise customer.」

這些不需要你做任何事。 但它們也不保護你不受自己的程式碼所害 —— 下面全部都是。


41.2 🔴 官方 best practices 裡最容易被忽略的一句

Section titled “41.2 🔴 官方 best practices 裡最容易被忽略的一句”

“Do not store request-scoped state in global scope.”

第 1 章就講過 isolate 會跨請求重用。在多租戶情境下這不是效能問題,是資料外洩。

// ❌ 模組層變數 = 跨租戶共享
let currentTenant: string;
export default {
async fetch(req, env) {
currentTenant = await resolveTenant(req); // A 使用者寫進去
const data = await load(env, currentTenant); // B 使用者可能讀到 A 的
},
};

這條規則在本系列已經出現過三次,各有各的化身:第 34 章(模組層的 AI client)、第 37 章(模組層的 McpServer singleton 會洩漏 MCP session)、以及這裡。一般化的規則是:任何在模組層被賦值、而值來自請求的東西,都是跨租戶洩漏。


41.3 Rate limiting:三個選擇,以及一個實測到的致命預設

Section titled “41.3 Rate limiting:三個選擇,以及一個實測到的致命預設”
計數範圍一致性適合
Rate Limiting bindingper-colo(每個 Cloudflare 機房各一份)最終一致、寬鬆應用層的粗略保護
WAF Rate Limiting Rules同樣 per-colo(cf.colo.id 是必填特徵)同上邊緣擋掉,不進 Worker
Durable Object全域唯一強一致(第 14 章)需要精確計數(配額、計費)

Rate Limiting binding 2025-09-19 GA。官方原文很誠實:

“For each unique key you pass to your rate limiting binding, there is a unique limit per Cloudflare location.”

“The above also means that the Rate Limiting API is permissive, eventually consistent, and intentionally designed to not be used as an accurate accounting system.”

所以:擋濫用可以,算配額不行。 要精確計數只有 DO 一條路 —— 但代價是每次請求都要走到那個 DO 所在的機房(第 14 章的延遲討論)。

⚠️ Cloudflare 沒有任何一頁比較這三者。 上面那張表是我從各頁的敘述拼出來的,不是官方的建議。

設定形狀(additionalProperties: false,打錯會被擋):

{
"ratelimits": [
{ "name": "PER_IP", "namespace_id": "1001", "simple": { "limit": 5, "period": 10 } }
]
}

period 只能是 1060 實測填 30:

✘ [ERROR] Processing wrangler.jsonc configuration:
- "ratelimits[0]" bindings "simple.period" must be either 10 or 60 but got 30.

⚠️ namespace_id 是帳號層級的。 兩個 Worker 用同一個 namespace_id,同一個 key 就共用計數器。複製貼上設定檔的時候特別容易中。

好消息是這個 binding 在本機是真的會擋的(不像第 30、34、35、36 章那些)。limit 5 / 10 秒,連打 8 次:

[{"success":true},{"success":true},{"success":true},{"success":true},{"success":true},
{"success":false},{"success":false},{"success":false}]

壞消息在邊界:

呼叫結果
limit({ key: "" }){ success: true } —— 空字串是合法的 key
limit({ key: "x".repeat(5000) }){ success: true } —— 沒有長度檢查
limit({ key: 42 })throw invalid key: 42
limit({ key: undefined }){ success: true } —— 不 throw
limit({})(完全沒有 key){ success: true }

而且我特別測了「沒有 key」會不會共用一個桶。用 limit 3 / 60 秒連打 8 次:

[true, true, true, false, false, false, false, false]

會。所有沒帶 key 的呼叫共用同一個全域桶。

🔴 這代表 env.RL.limit({ key: user?.id })userundefined 的時候,不會報錯、不會被型別擋下,而是把「所有未登入的請求」丟進同一個桶裡互相搶額度。 你的登入頁會在第 4 個匿名訪客就開始回 429,而且 log 上看起來一切正常。

注意數字會 throw,undefined 不會 —— 驗證是不一致的。

對策:自己包一層,key 是 string 且非空才呼叫,否則 fail closed。

async function limited(rl: RateLimit, key: string | undefined | null): Promise<boolean> {
// 沒有 key 就當作被限制,不要退化成共用桶
if (typeof key !== "string" || key.length === 0) return false;
const { success } = await rl.limit({ key });
return success;
}

另外,limit() 只回 { success } —— 沒有剩餘次數、沒有重置時間。想回 Retry-After header 只能自己算(用 period 推)。

key 該用什麼,官方有建議:API key、路徑、query 參數、user ID 與 tenant ID;並且不建議用 IP 或地區,因為「these can be shared by many users in many valid cases」。


41.4 🔴 D1 沒有 row-level security,一行都沒有

Section titled “41.4 🔴 D1 沒有 row-level security,一行都沒有”

這是多租戶 SaaS 最大的單一風險,而且平台完全不幫你

範例的 /probe/d1-isolation 塞了兩個租戶的資料,然後跑三個查詢:

{
"scopedToAcme": [ { "slug": "blog", ... }, { "slug": "launch", ... } ],
"predicateForgotten": [ { "slug": "blog", ... }, { "slug": "launch", ... },
{ "slug": "secret", "url": "https://globex.test/secret" } ],
"whatUserInputBuysYou": [ { "slug": "secret", "url": "https://globex.test/secret" } ]
}

第二筆就是忘了寫 WHERE tenant_id = ?。SQLite / D1 沒有 RLS、沒有 policy、沒有 session variable 可以讓引擎幫你過濾。忘了就是全洩。

第三筆更陰險:predicate 有寫,但 tenantId 來自使用者輸入。

唯一可靠的做法:讓「忘記」在型別上不可能。

class TenantDb {
constructor(private db: D1Database, private tenantId: string) {}
listLinks(limit: number) {
return this.db
.prepare("SELECT slug, url FROM links WHERE tenant_id = ?1 ORDER BY slug LIMIT ?2")
.bind(this.tenantId, limit)
.all();
}
}
// 呼叫端拿不到裸的 env.DB
const db = new TenantDb(env.DB, session.tenantId); // tenantId 來自驗證過的 session

再加三條規則:

  1. env.DB 只在一個檔案裡出現。 其他地方一律透過 TenantDb。用 lint 規則(no-restricted-properties)強制。
  2. tenantId 只能來自驗證過的 session / JWT claim(第 27 章),永遠不從 body、query 或 header 讀。 第 37 章的 MCP 工具參數是同一條規則的另一個化身:LLM 傳進來的參數是使用者輸入等級的資料。
  3. 複合主鍵永遠帶 tenant_idPRIMARY KEY (tenant_id, slug)。這樣即使 slug 撞了也不會跨租戶。

官方對 SaaS 資料隔離的建議(use-cases 頁)其實更保守:D1 是「isolated D1 databases per customer for complete data separation」,R2 是「prefix or bucket-level isolation」。每個租戶一個 D1 資料庫在租戶數量可控時是更強的邊界 —— 但要付出跨租戶查詢做不到的代價。


41.5 Durable Object 作為隔離邊界(以及官方其實沒這樣說)

Section titled “41.5 Durable Object 作為隔離邊界(以及官方其實沒這樣說)”

「每個租戶一個 DO」是社群裡很流行的模式,而且它確實給你:

  • 每個實例獨立的、交易性的儲存(第 15 章)
  • 單執行緒序列化執行(第 14 章的 input gate)
  • 實例數量無上限:官方 limits 頁寫「Number of Objects: Unlimited (within an account or of a given class)」,而且「Durable Objects are designed such that the number of individual objects in the system do not need to be limited, and can scale horizontally」

⚠️ 但官方從來沒有把 DO 描述成「安全隔離邊界」。 DO 的概念頁只講一致性與狀態,SaaS 資料隔離頁把 DO 定位成「Manage stateful workflows and provide strong consistency for multi-tenant operations」。

DO 給你的是狀態隔離,不是信任邊界。 你的 Worker 仍然能拿到任何租戶的 stub —— idFromName(tenantId) 用錯 tenantId 一樣全洩。真正的保護還是 41.4 那條:id 只能來自驗證過的 session。

要記的 DO 限制:每帳號 500 個 class(Paid)/ 100 個(Free);SQLite-backed 每個實例 10 GB,key+value 合計 ≤ 2 MB;WebSocket 訊息 32 MiB;每請求 CPU 預設 30 秒(SQLite-backed 可調到 5 分鐘)。

真正被官方文件定位成隔離產品的是 Workers for Platforms(第 33 章):

“By default, Workers inside of a dispatch namespace are considered ‘untrusted.’ This provides the strongest isolation between Workers… Each Worker has an isolated cache, when using the Cache API or when making subrequests using fetch().”

而 trusted 模式的警告是:「Workers can potentially access cached responses from other Workers in the namespace.」快取也是一個租戶邊界,這點很容易漏。


41.6 跑別人的程式碼:globalOutbound: null

Section titled “41.6 跑別人的程式碼:globalOutbound: null”

第 33 章講了 Worker Loaders 的機制,這裡是它的安全開關。實測(/probe/outbound):

{
"withDefaultOutbound": { "reached": true, "status": 403 },
"withNullOutbound": {
"reached": false,
"threw": "This worker is not permitted to access the internet via global functions like fetch().
It must use capabilities (such as bindings in 'env') to talk to the outside world."
}
}
const stub = env.LOADER.get(tenantId, async () => ({
compatibilityDate: "2026-07-24",
mainModule: "index.js",
modules: { "index.js": tenantCode },
globalOutbound: null, // ← 完全切斷網路
env: { STORE: scopedBinding }, // ← 只給你要給的能力
}));

錯誤訊息本身就是設計哲學:「It must use capabilities (such as bindings in env)」。這是 capability-based security —— 預設什麼都沒有,你明確給什麼它才有什麼。

這條本機測得出來,是本系列少數不需要 staging 的安全機制。

相關的 compatibility flag:global_fetch_strictly_public —— 「Restrict global fetch() to only make requests to public URLs」。這是 SSRF 防護:沒有它,Worker 的 fetch() 可以打到你自己 zone 上的非公開位址。任何會 fetch 使用者提供的 URL 的功能都應該開。


41.7 常數時間比較:Workers 上實際有什麼

Section titled “41.7 常數時間比較:Workers 上實際有什麼”

=== 比 API key 會透過時間洩漏長度與前綴。實測(/probe/timing-safe):

有沒有行為
crypto.subtle.timingSafeEqualfunction正確回 false
crypto.timingSafeEqualundefined
node:cryptotimingSafeEqual✅(要 nodejs_compat正確回 false

💡 crypto.subtle.timingSafeEqual 是 Workers 的擴充,標準 WebCrypto 沒有這個。 官方 best practices 明講「use crypto.subtle.timingSafeEqual() for comparing sensitive values」。不需要 nodejs_compat優先用這個

🔴 兩邊都有同一個陷阱:長度不同會 throw,不是回 false

TypeError: Input buffers must have the same byte length.
// ❌ 使用者送一個長度不對的 token 就會 500
if (crypto.subtle.timingSafeEqual(given, expected)) { /* ... */ }
// ✅ 先 hash 成固定長度再比
const h = async (s: string) =>
new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)));
const ok = crypto.subtle.timingSafeEqual(await h(given), await h(expected));

先 hash 再比同時解決了長度不同會 throw、以及「先比長度」本身會洩漏長度這兩件事。

順帶一提官方另一條:「Never use Math.random() for anything security-sensitive」 —— 用 crypto.getRandomValues()crypto.randomUUID()(後者在模組層呼叫會 throw,第 1 章實測過)。


41.8 Turnstile:五分鐘、一次性、要帶 idempotency key

Section titled “41.8 Turnstile:五分鐘、一次性、要帶 idempotency key”

Turnstile 是 GA,Free 方案就有(20 個 widget、每個 10 個 hostname、無限次挑戰)。

沒有 Workers binding。 官方的整合方式就是 fetch

const form = new FormData();
form.append("secret", env.TURNSTILE_SECRET); // 用 wrangler secret 存
form.append("response", token);
form.append("remoteip", req.headers.get("cf-connecting-ip") ?? "");
form.append("idempotency_key", crypto.randomUUID()); // ← 重試時要重用同一個
const r = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST", body: form,
});

三條會咬人的規則:

  1. token 五分鐘後過期。
  2. 每個 token 只能驗證一次。
  3. 過期或重複驗證都會得到 timeout-or-duplicate 這個 error code。

🔴 第 2 條加第 3 條的後果:你的重試邏輯(第 19 章那些 queue retry)如果不帶或每次換新的 idempotency_key,第二次重試就會被判定成重放攻擊。官方對 idempotency_key 的定義是「A UUID you generate to safely retry validation requests」—— 同一次驗證的所有重試必須用同一個 UUID。

token 最長 2048 字元。


“Presigned URLs are generated client-side with no communication with R2, requiring only your R2 API credentials and an implementation of the AWS Signature Version 4 signing algorithm.”

  • 有效期 1 秒到 7 天(604,800 秒)。
  • 🔴 只能用 S3 API 網域(<ACCOUNT_ID>.r2.cloudflarestorage.com),不能用自訂網域。 做白牌 SaaS 的話這是硬傷。
  • 不支援 POST(HTML form 的 multipart 上傳)。
  • 一個 presigned URL 只授權單一物件的單一操作

在 Worker 裡用 aws4fetch(它用的就是 fetch + SubtleCrypto):

import { AwsClient } from "aws4fetch";
const client = new AwsClient({ accessKeyId, secretAccessKey });
const signed = await client.sign(
new Request(`${R2_URL}/bucket/${key}?X-Amz-Expires=3600`, { method: "GET" }),
{ aws: { signQuery: true } },
);
// signed.url 就是 presigned URL

R2 的 presigned-URL 文件頁只示範 AWS SDK / CLI,Worker 裡的做法要看 aws4fetch 那一頁。

HMAC-SHA-256 簽 pathname + query string(含 exp,hex 之後放進 sig

const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(env.IMAGES_SIGNING_KEY),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"],
);
url.searchParams.set("exp", String(Math.floor(Date.now() / 1000) + 300));
const mac = await crypto.subtle.sign(
"HMAC", key, new TextEncoder().encode(url.pathname + "?" + url.searchParams.toString()),
);
url.searchParams.set("sig", [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join(""));

🔴 Images binding 不支援簽名。 第 31 章那個 binding 只有 .input() / .transform() / .draw() / .output() / .info()簽名要自己用 WebCrypto 寫。

另外「Private images do not currently support custom paths」,而且 variant 如果設成 always allow public access,簽名就形同虛設。


41.10 Cloudflare Access 保護管理後台

Section titled “41.10 Cloudflare Access 保護管理後台”

把 admin 路由丟在 Cloudflare Access 後面,Worker 只要驗 JWT:

  • header:Cf-Access-Jwt-Assertion驗 header,不要驗 CF_Authorization cookie —— 官方說 cookie 的傳遞沒有保證)
  • certs:https://<team-name>.cloudflareaccess.com/cdn-cgi/access/certs
  • 官方示範用 jose(第 27 章已經裝過了):
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(
new URL(`https://${TEAM}.cloudflareaccess.com/cdn-cgi/access/certs`),
);
const { payload } = await jwtVerify(req.headers.get("Cf-Access-Jwt-Assertion")!, JWKS, {
issuer: `https://${TEAM}.cloudflareaccess.com`,
audience: AUD, // ← 這個不能省
});

🔴 audience(AUD tag)一定要驗。 不驗的話,同一個帳號底下另一個 Access application 簽出來的 token 也會通過。這是最常見的 Access 設定漏洞。

官方另一條警告:不要寫死 public key,要用 JWT 的 kidpublic_certs 裡找 —— 快取住的值會在金鑰輪替時失效。

沒有第一方的 helper library,jose 就是官方的做法。


“When a namespace is specified in a query operation, only vectors within that namespace are used for the search. Namespace filtering is applied before vector search.”

  • 一個向量只能屬於一個 namespace。
  • namespace 名稱最長 64 bytes
  • namespace filter 在 metadata filter 之前套用。

⚠️ 限制數字兩頁互相矛盾:best-practices 頁寫「up to 1,000 namespaces per index」,limits 頁寫「50,000(Workers Paid)/ 1000(Free)」。合理的讀法是 limits 頁比較新、best-practices 的散文過時了,但引用前自己確認。

還是那句話:namespace 是你的 Worker 傳進去的查詢參數,不是被強制的憑證邊界。 傳錯 tenant id 一樣洩漏。Cloudflare 沒有任何 server-side 的 per-tenant ACL。

(對照:AI Search 的 multitenancy 頁建議的是每個租戶一個 instance —— 「Choose it when you need strong isolation」。同樣的取捨在 41.4 的 D1 也出現過。)

Workers with Assets 的 _headers 檔有兩個限制要記:

🔴 “Custom headers defined in the _headers file are not applied to responses generated by your Worker code, even if the request URL matches a rule.”

SSR 的回應完全不吃 _headers 第 24、25 章那些 React Router / Astro 的專案,安全 header 必須在 Worker 的 Response 裡自己加。

“The CORS specification only allows *, null, or an exact origin as valid Access-Control-Allow-Origin values — wildcard patterns within origins are not supported.”

沒有 https://*.example.com 這種東西。 要支援多個 origin 只能自己比對 Origin header 再回一個確切值(記得加 Vary: Origin)。

上限:100 條 header 規則、每行 2000 字元。

Workers 沒有通用的 CSRF 文件。 唯一有實質內容的是 MCP server 那頁(第 37 章),但其中一條適用於所有 Worker:

🔴 “Without __Host-, an attacker controlling evil.workers.dev could set cookies for your mcp-server.workers.dev domain.”

所有掛在 *.workers.dev 上的 Worker 都共用一個可註冊網域,任何人都能在上面部署。cookie 一律用 __Host- 前綴(強制 SecurePath=/、無 Domain),否則別人的 workers.dev Worker 可以幫你的 Worker 設 cookie。第 27 章那個 session cookie 在這裡要補上這條。


41.12 供應鏈:Workers 沒有 filesystem,但風險一樣在

Section titled “41.12 供應鏈:Workers 沒有 filesystem,但風險一樣在”

Workers 沒有檔案系統、沒有 child process,所以典型的 postinstall 惡意腳本在 runtime 傷不到你 —— 但它在你的 CI 機器上照跑不誤(第 40 章)。而執行期的風險完全沒變:一個被投毒的套件可以讀你的 env(所有 secret)並把它 fetch 出去。

2026-07-07 起 wrangler 會在 deployversions upload 時收集 npm 相依資料:每個相依套件的名稱、宣告的版本範圍、實際安裝的版本。官方說法是這「enables dependency analytics and future supply chain security features such as vulnerability alerting」。

實測 schema 裡的設定:

{
"dependencies_instrumentation": {
"enabled": false, // 預設 true
"exclude_packages": ["@internal/*"] // 支援 * 萬用字元
}
}

⚠️ 注意這是「未來的」漏洞告警,不是已經在跑的掃描器。 現在還沒有 Workers 的 SBOM 匯出,官方也沒有 npm audit 的指引。還是要自己在 CI 裡跑。

如果你有內部私有套件不想把名字送出去,用 exclude_packages

實務上的三條:

  1. CI 裡跑 npm audit --audit-level=high,並且用 lockfilenpm ci,第 40 章)。
  2. 能不加就不加。 Workers 的 bundle 大小是硬限制,這剛好和供應鏈風險同向。
  3. env 是最高價值目標。 敏感的 secret 用 Secrets Store(第 40 章)而不是 Worker secret,這樣至少 blast radius 是「一個 secret」而不是「這個 Worker 的全部」。

#威脅邊界對策平台幫忙嗎
1租戶 A 讀到租戶 B 的短網址D1 rowTenantDb 包裝 + PRIMARY KEY (tenant_id, slug) + lint 禁止裸用 env.DB完全沒有
2租戶 id 被竄改session只從驗證過的 JWT claim 讀(第 27 章)
3跨租戶的模組層變數洩漏isolate禁止任何請求相關的模組層變數
4建立短網址被濫用rate limitbinding + key 必須非空字串,否則 fail closed⚠️ 有但預設危險
5精確的方案配額計費DO,不是 rate limit binding(後者明說不能拿來記帳)
6表單機器人Turnstilesiteverify + 重試共用 idempotency key
7管理後台AccessCf-Access-Jwt-Assertion + 必驗 audience
8API key 比對的時間側通道crypto先 SHA-256 再 crypto.subtle.timingSafeEqual
9私有圖片外流Images自己用 HMAC 簽(binding 不做)
10R2 物件外流R2presigned URL,≤ 7 天,只能用 S3 網域
11租戶自訂重導規則(跑使用者程式碼)Worker LoaderglobalOutbound: null + 只給必要 binding
12打內網的 SSRFfetchglobal_fetch_strictly_public flag
13別人的 workers.dev Worker 幫你設 cookiecookie__Host- 前綴
14SSR 頁面缺安全 headerheader在 Worker 的 Response 裡加,_headers 對 SSR 無效
15相依套件投毒供應鏈lockfile + npm audit + 少加套件 + Secrets Store⚠️ 只有 metadata 收集
16向量檢索跨租戶Vectorizenamespace(但那只是查詢參數
17快取跨租戶CacheWfP untrusted 模式才有隔離快取⚠️ 看模式

數一下:17 條裡面平台幫你守住的只有 6 條。


#結論影響
1isolate 隔離、Spectre 緩解、信任分層都是平台做的但保護不了你自己的程式碼
2官方 best practice:「Do not store request-scoped state in global scope」多租戶下這是資料外洩(第 34、37 章同款)
3Rate Limiting binding 2025-09 GA;計數是 per-colo、最終一致官方明說「intentionally designed to not be used as an accurate accounting system」
4WAF rate limiting rules 也是 per-colo(cf.colo.id 必填)只有 DO 是全域強一致
5Cloudflare 沒有任何一頁比較 binding / WAF / DO本章那張表是我拼的,不是官方建議
6simple.period 只能 10 或 60,填 30 被 wrangler 擋下錯誤訊息很清楚
7namespace_id帳號層級,不同 Worker 用同一個就共用計數器複製設定檔的陷阱
8rate limit binding 本機真的會擋(5/10s → 5 個 true 3 個 false)少數不需要 staging 的機制
9🔴 limit({})(沒有 key)回 success: true,而且所有這種呼叫共用一個全域桶(實測 3/60 → [t,t,t,f,f,f,f,f]limit({ key: user?.id }) 在 undefined 時會拖垮所有匿名使用者
10🔴 key: 42 throw,但 key: undefinedkey: "" 不 throw驗證不一致;自己包一層 fail closed
11limit() 只回 { success }沒有剩餘次數 / 重置時間,Retry-After 要自己算
12官方建議 key 用 user/tenant ID,不建議用 IP 或地區
13🔴 D1 沒有 RLS、沒有 policy、沒有 session variable;實測忘記 predicate 就全洩唯一解是讓「忘記」在型別上不可能
14官方 SaaS 建議其實是每租戶一個 D1 資料庫代價是跨租戶查詢做不到
15DO 實例數無上限;每帳號 500 個 class(Paid)/ 100(Free);SQLite-backed 每實例 10 GB
16⚠️ 官方從沒把 DO 描述成安全隔離邊界,只講一致性與狀態DO 給的是狀態隔離,不是信任邊界
17被官方定位成隔離產品的是 Workers for Platforms;untrusted 模式連 cache 都隔離trusted 模式會跨 Worker 共用快取
18globalOutbound: null 本機實測有效,錯誤訊息點名 capability-based 設計「It must use capabilities (such as bindings in env)」
19global_fetch_strictly_public flag 限制 fetch() 只能打公開 URLSSRF 防護
20crypto.subtle.timingSafeEqual 在 Workers 上存在(標準 WebCrypto 沒有);crypto.timingSafeEqual 不存在不需要 nodejs_compat
21🔴 長度不同會 TypeError: Input buffers must have the same byte length.,不是回 false先 SHA-256 再比
22官方:「Never use Math.random() for anything security-sensitive」
23Turnstile 沒有 Workers binding,就是 fetch siteverify
24🔴 token 5 分鐘過期、只能驗一次,失敗回 timeout-or-duplicate重試必須重用同一個 idempotency_key
25R2 presigned URL 有效期 1 秒 – 7 天不能用自訂網域;不支援 POSTWorker 裡用 aws4fetch
26🔴 Images binding 不支援簽名(只有 input/transform/draw/output/info)自己用 HMAC-SHA-256 簽 pathname+query
27Access:驗 Cf-Access-Jwt-Assertion header,不要驗 cookiecookie 傳遞沒有保證
28🔴 一定要驗 audience(AUD tag),否則同帳號其他 application 的 token 也會通過最常見的 Access 漏洞
29不要寫死 public key,用 kidpublic_certs金鑰輪替
30Vectorize:一個向量只能屬於一個 namespace;名稱 ≤ 64 bytes;namespace filter 先於 metadata filter
31⚠️ namespace 數量兩頁矛盾:best-practices 寫 1,000,limits 頁寫 50,000(Paid)/ 1,000(Free)引用前自己確認
32namespace / metadata filter 都只是查詢參數,不是強制邊界傳錯 tenant id 一樣洩漏
33🔴 _headers 對 Worker 產生的回應(SSR)完全無效第 24、25 章的安全 header 要在 Response 裡加
34Access-Control-Allow-Origin 只能是 *null 或確切 origin,沒有萬用字元 pattern多 origin 要自己比對 + Vary: Origin
35🔴 *.workers.dev 是共用的可註冊網域:別人的 Worker 能幫你設 cookiecookie 一律用 __Host- 前綴
362026-07-07 起 wrangler 上傳 npm 相依 metadata(名稱 / 版本範圍 / 實際版本)是「未來的」漏洞告警,不是現在的掃描器
37實測 schema 有 dependencies_instrumentation: { enabled, exclude_packages }exclude_packages 支援 *私有套件名稱可以排除
38Workers 沒有 npm audit 指引、沒有 SBOM 匯出自己在 CI 做

  1. env.RL.limit({ key: user?.id }) 裡的 user 弄成 undefined,連打十次,觀察你的匿名使用者怎麼互相搶額度。然後包一層 fail-closed 的 helper。
  2. simple.period 設成 30,記下 wrangler 的完整錯誤訊息。
  3. 在 D1 裡塞兩個租戶的資料,故意寫一個忘記 WHERE tenant_id 的查詢,看它回什麼。然後寫一條 no-restricted-properties lint 規則禁止裸用 env.DB
  4. crypto.subtle.timingSafeEqual 比兩個長度不同的字串,確認它 throw;改成先 SHA-256 再比。
  5. 用 Worker Loader 跑一段會 fetch("https://example.com") 的程式碼,分別在有無 globalOutbound: null 的情況下跑,記下錯誤訊息。
  6. 把一個 Turnstile token 驗證兩次,確認第二次回 timeout-or-duplicate
  7. 檢查你所有的 cookie 有沒有 __Host- 前綴。

下一章:第 42 章 成本工程 —— 把全系列的計費維度收攏成一張表,並算出 LinkForge 在三個量級的月成本。