跳到內容

ch41-security

取得並執行

這個範例可以獨立 clone 執行,不依賴其他章節。

npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch41-security
cd ch41-security
npm install

可用指令

npm run dev	# wrangler dev
npm run types	# wrangler types --env-interface Env

說明

ch41-security — where the platform helps, and where it doesn’t

Section titled “ch41-security — where the platform helps, and where it doesn’t”

Companion example for chapter 41. Measured with wrangler 4.118.0, compatibility date 2026-07-24, on 2026-08-01.

Terminal window
npm install
npm run dev
RouteShows
GET /probe/ratelimit?key=a8 calls against a 5-per-10s limiter
GET /probe/ratelimit-edgesempty / oversized / non-string / missing / undefined key
GET /probe/timing-safewhat constant-time comparison exists on Workers
GET /probe/outboundglobalOutbound: null vs the default
GET /probe/d1-isolationwhat a forgotten WHERE tenant_id costs you
GET /tenant/<slug>the tenant-scoped wrapper (needs x-demo-tenant)

Finding 1 — limit() with no key succeeds, and shares one global bucket

Section titled “Finding 1 — limit() with no key succeeds, and shares one global bucket”

The rate limiting binding does enforce locally, which makes it one of the few security mechanisms in this series you can develop against offline. Eight calls against limit: 5, period: 10:

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

The edges are where it gets dangerous:

CallResult
limit({ key: "" }){ success: true }
limit({ key: "x".repeat(5000) }){ success: true }
limit({ key: 42 })throws invalid key: 42
limit({ key: undefined }){ success: true }
limit({}){ success: true }

And keyless calls are not free — they share one bucket. Eight calls against a limit: 3, period: 60 limiter with {}:

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

So env.RL.limit({ key: user?.id }) with an undefined user does not throw, does not fail type-checking, and quietly throws every anonymous request into a single shared bucket. Your login page starts returning 429 at the fourth anonymous visitor and the logs look fine.

Note the inconsistency: a number throws, undefined and "" do not.

// Fail closed on a missing key rather than degrading into a shared bucket.
async function limited(rl: RateLimit, key: string | undefined | null): Promise<boolean> {
if (typeof key !== "string" || key.length === 0) return false;
const { success } = await rl.limit({ key });
return success;
}

limit() returns only { success } — no remaining count, no reset time, so a Retry-After header has to be synthesized from period.

Config note: simple.period accepts only 10 or 60.

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

And namespace_id is account-scoped: two Workers sharing one id share counters for the same key.

Finding 2 — D1 has no row-level security, demonstrated

Section titled “Finding 2 — D1 has no row-level security, demonstrated”

/probe/d1-isolation seeds two tenants and runs three queries:

{
"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"} ]
}

The second is the same query with WHERE tenant_id = ? omitted. SQLite and D1 have no RLS, no policies, and no session variable the engine could filter on. There is nothing between “forgot the predicate” and “returned every tenant’s rows”.

The mitigation is to make forgetting impossible, not to remember harder:

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();
}
}
const db = new TenantDb(env.DB, session.tenantId); // never from the request body

Plus: env.DB appears in exactly one file (enforce with a no-restricted-properties lint rule), and every table’s primary key starts with tenant_id.

Finding 3 — globalOutbound: null works, and says why

Section titled “Finding 3 — globalOutbound: null works, and says why”
{
"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."
}
}

The error message is the design philosophy: capability-based security. Untrusted code gets nothing by default and only what you hand it through env.

Finding 4 — constant-time comparison, and its footgun

Section titled “Finding 4 — constant-time comparison, and its footgun”
PresentBehaviour
crypto.subtle.timingSafeEqualyescorrectly returns false
crypto.timingSafeEqualno (undefined)
node:crypto’s timingSafeEqualyes (needs nodejs_compat)correctly returns false

crypto.subtle.timingSafeEqual is a Workers extension — standard WebCrypto has no such method — and it is what Cloudflare’s own best-practices page tells you to use. It needs no compatibility flag.

Both implementations share one trap: different lengths throw rather than returning false.

TypeError: Input buffers must have the same byte length.

A user-supplied token of the wrong length turns into a 500. Hash both sides to a fixed length first, which also avoids leaking the length through an explicit length check:

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));

From the chapter’s threat model: of 17 threats in a multi-tenant link shortener, the platform covers 6. The other 11 — tenant scoping in D1, tenant id provenance, module-scope leakage, rate-limit keys, exact quota accounting, Images URL signing, __Host- cookies on the shared workers.dev registrable domain, security headers on SSR responses (the _headers file does not apply to Worker-generated responses), Vectorize namespace correctness, and dependency auditing — are entirely application-level.

原始碼

wrangler.jsonc

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "ch41-security",
  "main": "src/index.ts",
  "compatibility_date": "2026-07-24",
  "compatibility_flags": ["nodejs_compat"],
  "observability": { "enabled": true },
  "ratelimits": [
    { "name": "PER_IP", "namespace_id": "1001", "simple": { "limit": 5, "period": 10 } },
    { "name": "PER_TENANT", "namespace_id": "1002", "simple": { "limit": 3, "period": 60 } }
  ],
  "d1_databases": [{ "binding": "DB", "database_name": "ch41", "database_id": "ch41-local" }],
  "worker_loaders": [{ "binding": "LOADER" }]
}

package.json

{
  "name": "ch41-security",
  "private": true,
  "type": "module",
  "scripts": { "dev": "wrangler dev", "types": "wrangler types --env-interface Env" },
  "devDependencies": { "wrangler": "^4.118.0", "typescript": "^5.9.2", "@types/node": "^22.0.0" }
}

src/index.ts

const t = async (fn: () => unknown): Promise<Record<string, unknown>> => {
  const started = Date.now();
  try {
    const v = await fn();
    return { ok: v === undefined ? "(undefined)" : (v as object), ms: Date.now() - started };
  } catch (e) {
    const err = e as Error & { code?: number };
    return {
      threwName: err.name,
      code: err.code,
      threw: String(err.message ?? e).slice(0, 250),
      ms: Date.now() - started,
    };
  }
};

/**
 * The only safe way to talk to D1 in a multi-tenant app: the tenant id never
 * comes from the request body, and every statement is built through a helper
 * that cannot forget the predicate. D1 has no row-level security -- isolation
 * is entirely your application's job.
 */
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();
  }

  getLink(slug: string) {
    return this.db
      .prepare("SELECT slug, url FROM links WHERE tenant_id = ?1 AND slug = ?2")
      .bind(this.tenantId, slug)
      .first();
  }
}

export default {
  async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
    const url = new URL(req.url);

    // ---- Rate limiting binding -----------------------------------------
    if (url.pathname === "/probe/ratelimit") {
      const key = url.searchParams.get("key") ?? "anon";
      const results: unknown[] = [];
      for (let i = 0; i < 8; i++) {
        results.push(await t(() => env.PER_IP.limit({ key })));
      }
      return Response.json({
        // Shape of the binding itself. If it is an RPC-style stub, feature
        // detection will lie (the ch30 rule).
        proto: Object.getPrototypeOf(env.PER_IP)?.constructor?.name,
        limitType: typeof env.PER_IP.limit,
        results,
      });
    }

    // A non-string key, and an empty key. Neither is in the type, both are
    // things a careless caller will produce.
    if (url.pathname === "/probe/ratelimit-edges") {
      return Response.json({
        emptyKey: await t(() => env.PER_IP.limit({ key: "" })),
        veryLongKey: await t(() => env.PER_IP.limit({ key: "x".repeat(5000) })),
        numberKey: await t(() =>
          (env.PER_IP.limit as unknown as (o: { key: unknown }) => unknown)({ key: 42 }),
        ),
        noKey: await t(() =>
          (env.PER_IP.limit as unknown as (o: object) => unknown)({}),
        ),
        // Does a missing key share ONE bucket? Call it until it trips.
        noKeyRepeated: await t(async () => {
          const out: unknown[] = [];
          for (let i = 0; i < 8; i++) {
            out.push(await (env.PER_TENANT.limit as unknown as (o: object) => Promise<{ success: boolean }>)({}));
          }
          return out.map((r) => (r as { success: boolean }).success);
        }),
        // undefined key -- what `limit({ key: user?.id })` produces.
        undefinedKey: await t(() =>
          (env.PER_IP.limit as unknown as (o: { key: unknown }) => unknown)({ key: undefined }),
        ),
      });
    }

    // ---- Constant-time comparison --------------------------------------
    // Comparing an API key with === leaks its length and prefix through
    // timing. What is actually available on Workers?
    if (url.pathname === "/probe/timing-safe") {
      const a = new TextEncoder().encode("correct-horse-battery-staple");
      const b = new TextEncoder().encode("correct-horse-battery-stapl3");
      const subtle = crypto.subtle as unknown as Record<string, unknown>;
      return Response.json({
        "crypto.subtle.timingSafeEqual": typeof subtle.timingSafeEqual,
        "crypto.timingSafeEqual": typeof (crypto as unknown as Record<string, unknown>)
          .timingSafeEqual,
        subtleCall: await t(() =>
          (subtle.timingSafeEqual as (x: ArrayBufferView, y: ArrayBufferView) => boolean)?.(a, b),
        ),
        subtleDifferentLengths: await t(() =>
          (subtle.timingSafeEqual as (x: ArrayBufferView, y: ArrayBufferView) => boolean)(
            a,
            new TextEncoder().encode("short"),
          ),
        ),
        nodeCrypto: await t(async () => {
          const m = (await import("node:crypto")) as unknown as {
            timingSafeEqual?: (x: Uint8Array, y: Uint8Array) => boolean;
          };
          return { has: typeof m.timingSafeEqual, result: m.timingSafeEqual?.(a, b) };
        }),
        // Different lengths is the classic footgun -- most implementations
        // throw rather than return false.
        differentLengths: await t(async () => {
          const m = (await import("node:crypto")) as unknown as {
            timingSafeEqual: (x: Uint8Array, y: Uint8Array) => boolean;
          };
          return m.timingSafeEqual(a, new TextEncoder().encode("short"));
        }),
      });
    }

    // ---- Untrusted code: the outbound kill switch -----------------------
    // ch33 covered Worker Loaders. The security-relevant part is
    // globalOutbound: null -- with it, tenant code cannot reach the network
    // at all, including your own internal services.
    if (url.pathname === "/probe/outbound") {
      const run = async (globalOutbound: null | undefined) => {
        const stub = env.LOADER.get(`probe-${globalOutbound === null ? "null" : "default"}`, async () => ({
          compatibilityDate: "2026-07-24",
          mainModule: "index.js",
          modules: {
            "index.js": `export default {
              async fetch() {
                try {
                  const r = await fetch("https://example.com/");
                  return Response.json({ reached: true, status: r.status });
                } catch (e) {
                  return Response.json({ reached: false, threw: String(e.message ?? e).slice(0, 160) });
                }
              }
            };`,
          },
          globalOutbound,
        }));
        const entry = await stub.getEntrypoint();
        const res = await entry.fetch("https://internal/");
        return await res.json();
      };

      return Response.json({
        withDefaultOutbound: await t(() => run(undefined)),
        withNullOutbound: await t(() => run(null)),
      });
    }

    // ---- D1 has no row-level security ----------------------------------
    if (url.pathname === "/probe/d1-isolation") {
      await env.DB.exec(
        "CREATE TABLE IF NOT EXISTS links (tenant_id TEXT NOT NULL, slug TEXT NOT NULL, url TEXT NOT NULL, PRIMARY KEY (tenant_id, slug))",
      );
      await env.DB.batch([
        env.DB.prepare("INSERT OR REPLACE INTO links VALUES ('acme', 'launch', 'https://acme.test/launch')"),
        env.DB.prepare("INSERT OR REPLACE INTO links VALUES ('acme', 'blog',   'https://acme.test/blog')"),
        env.DB.prepare("INSERT OR REPLACE INTO links VALUES ('globex', 'secret', 'https://globex.test/secret')"),
      ]);

      const scoped = await new TenantDb(env.DB, "acme").listLinks(20);

      // The same query with the predicate forgotten. There is no database-side
      // mechanism that stops this -- SQLite/D1 has no RLS, no policies, and
      // no session variable the engine could filter on.
      const forgotten = await env.DB.prepare("SELECT slug, url FROM links ORDER BY slug LIMIT 20").all();

      // And the classic: a tenant id taken from user input.
      const attackerControlled = await new TenantDb(env.DB, "globex").listLinks(20);

      return Response.json({
        scopedToAcme: scoped.results,
        predicateForgotten: forgotten.results,
        whatUserInputBuysYou: attackerControlled.results,
      });
    }

    // ---- Tenant-scoped D1 ----------------------------------------------
    if (url.pathname.startsWith("/tenant/")) {
      // The tenant id comes from the verified session, NEVER from the body
      // or a query parameter. Here it is faked from a header to keep the
      // example short -- in production this is the JWT claim from ch27.
      const tenantId = req.headers.get("x-demo-tenant");
      if (tenantId === null) return new Response("unauthenticated", { status: 401 });

      const db = new TenantDb(env.DB, tenantId);
      const slug = url.pathname.slice("/tenant/".length);
      return Response.json(slug ? await db.getLink(slug) : await db.listLinks(20));
    }

    return new Response(
      [
        "ch41-security probes:",
        "  GET /probe/ratelimit?key=a   8 calls against a limit of 5/10s",
        "  GET /probe/ratelimit-edges   empty / oversized / non-string / missing key",
        "  GET /probe/timing-safe       what constant-time comparison exists",
        "  GET /probe/outbound          globalOutbound: null vs default",
        "  GET /tenant/<slug>           tenant-scoped D1 (needs x-demo-tenant)",
      ].join("\n"),
      { headers: { "content-type": "text/plain" } },
    );
  },
} satisfies ExportedHandler<Env>;

.gitignore

node_modules/
.wrangler/
worker-configuration.d.ts

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext", "lib": ["esnext"], "module": "esnext",
    "moduleResolution": "bundler", "types": ["./worker-configuration.d.ts", "node"],
    "strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true
  },
  "include": ["src/**/*.ts", "worker-configuration.d.ts"]
}