From 86eb6d23a671f88bcd4a9cbcf18bb40710c7bce5 Mon Sep 17 00:00:00 2001 From: Daniel Bodnar <1790726+danielbodnar@users.noreply.github.com> Date: Thu, 23 Jul 2026 03:11:06 -0500 Subject: [PATCH] fix(deploy): make bootstrap gate fail-closed by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile P1 follow-up: an unset or empty BOOTSTRAP_TOKEN no longer leaves the unclaimed operator invite exposed — the Worker serves only a setup page until the secret exists. Post-claim, operators open the deployment explicitly with DISABLE_BOOTSTRAP_GATE="true". Claude-Session: https://claude.ai/code/session_01NWpwXBqxrPZdv75ud8xszr --- deploy/cloudflare/README.md | 2 +- deploy/cloudflare/src/index.ts | 38 +++++++++++++++++------ deploy/cloudflare/src/lib.ts | 50 ++++++++++++++++++++++++++++++ deploy/cloudflare/test/lib.test.ts | 30 ++++++++++++++++++ deploy/cloudflare/wrangler.jsonc | 7 ++++- docs/deploy/cloudflare.md | 30 ++++++++---------- 6 files changed, 129 insertions(+), 28 deletions(-) diff --git a/deploy/cloudflare/README.md b/deploy/cloudflare/README.md index 7da32ffb29..cd7245c0ea 100644 --- a/deploy/cloudflare/README.md +++ b/deploy/cloudflare/README.md @@ -9,8 +9,8 @@ preinstalled. No custom domain required. ```sh pnpm install npx wrangler login -npx wrangler secret put BOOTSTRAP_TOKEN # gate access until you claim the operator account npx wrangler deploy +npx wrangler secret put BOOTSTRAP_TOKEN # required — deployment is fail-closed until set ``` > ⚠️ Container disk is **ephemeral** — for any data you want to keep, set an diff --git a/deploy/cloudflare/src/index.ts b/deploy/cloudflare/src/index.ts index a753ab6762..0f0bf1df0d 100644 --- a/deploy/cloudflare/src/index.ts +++ b/deploy/cloudflare/src/index.ts @@ -24,8 +24,10 @@ import { STORAGE_MOUNT_PATH, accessDeniedPage, bootingResponse, + bootstrapGateMode, buildPaperclipEnv, getCookie, + setupRequiredPage, isMountAlreadyInUse, isPaperclipRunning, isTransientBootError, @@ -54,12 +56,18 @@ interface Env { ANTHROPIC_API_KEY?: string; DATABASE_URL?: string; /** - * When set, every request must present this token (?bootstrap_token=…, - * which sets a cookie) — protects the unclaimed operator invite between - * first boot and the operator's first login. Delete the secret after - * claiming the account to open the login page to your team. + * Required before the deployment serves anything (fail-closed): every + * request must present this token (?bootstrap_token=…, which sets a + * cookie) — protects the unclaimed operator invite between first boot and + * the operator's first login. */ BOOTSTRAP_TOKEN?: string; + /** + * Set to "true" (wrangler.jsonc vars) after the operator account is + * claimed to open the login page to your team; Paperclip's own auth + * protects everything from then on. + */ + DISABLE_BOOTSTRAP_GATE?: string; } /** @@ -92,15 +100,27 @@ async function tokensMatch(presented: string, expected: string): Promise { - if (!env.BOOTSTRAP_TOKEN) return null; + const mode = bootstrapGateMode({ + token: env.BOOTSTRAP_TOKEN, + disableGate: env.DISABLE_BOOTSTRAP_GATE, + }); + if (mode === "open") return null; + if (mode === "setup") { + return new Response(setupRequiredPage(), { + status: 403, + headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }, + }); + } + // mode === "token": BOOTSTRAP_TOKEN is guaranteed non-empty here. const presented = url.searchParams.get(BOOTSTRAP_PARAM); - if (presented !== null && (await tokensMatch(presented, env.BOOTSTRAP_TOKEN))) { + if (presented !== null && (await tokensMatch(presented, env.BOOTSTRAP_TOKEN!))) { // Strip the token from the URL and persist access in a cookie. url.searchParams.delete(BOOTSTRAP_PARAM); return new Response(null, { @@ -108,14 +128,14 @@ async function enforceBootstrapGate(request: Request, env: Env, url: URL): Promi headers: { location: url.toString(), "set-cookie": - `${BOOTSTRAP_COOKIE}=${encodeURIComponent(env.BOOTSTRAP_TOKEN)}; ` + + `${BOOTSTRAP_COOKIE}=${encodeURIComponent(env.BOOTSTRAP_TOKEN!)}; ` + "HttpOnly; Secure; SameSite=Lax; Path=/", }, }); } const cookie = getCookie(request.headers.get("Cookie"), BOOTSTRAP_COOKIE); - if (cookie !== undefined && (await tokensMatch(decodeURIComponent(cookie), env.BOOTSTRAP_TOKEN))) { + if (cookie !== undefined && (await tokensMatch(decodeURIComponent(cookie), env.BOOTSTRAP_TOKEN!))) { return null; } diff --git a/deploy/cloudflare/src/lib.ts b/deploy/cloudflare/src/lib.ts index 3b578e2a9a..d9eb573948 100644 --- a/deploy/cloudflare/src/lib.ts +++ b/deploy/cloudflare/src/lib.ts @@ -90,6 +90,56 @@ export function getCookie(cookieHeader: string | null, name: string): string | u return undefined; } +/** + * Access-gate decision, fail-closed by default: + * - "open" — operator explicitly disabled the gate (post-claim state) + * - "setup" — no usable token configured; serve the setup page, proxy nothing + * - "token" — token configured; require it (query param once, cookie after) + * An empty-string token counts as unconfigured — it must never open the gate. + */ +export function bootstrapGateMode(options: { + token?: string; + disableGate?: string; +}): "open" | "setup" | "token" { + if (options.disableGate === "true") return "open"; + if (!options.token) return "setup"; + return "token"; +} + +/** 403 page served fail-closed until BOOTSTRAP_TOKEN is configured. */ +export function setupRequiredPage(): string { + return ` + + + + +Paperclip — setup required + + + +
+

Setup required

+

This Paperclip deployment starts locked so that nobody + else can claim the operator account before you do.

+

Set a bootstrap token, then open this URL with + ?${BOOTSTRAP_PARAM}=<your token>:

+

npx wrangler secret put BOOTSTRAP_TOKEN

+

After you claim the operator account you can open the deployment to your + team by setting DISABLE_BOOTSTRAP_GATE to "true" + in wrangler.jsonc and redeploying.

+
+ +`; +} + /** 401 page shown while the deployment is gated by BOOTSTRAP_TOKEN. */ export function accessDeniedPage(): string { return ` diff --git a/deploy/cloudflare/test/lib.test.ts b/deploy/cloudflare/test/lib.test.ts index ae2cab17ec..ad4d4b1246 100644 --- a/deploy/cloudflare/test/lib.test.ts +++ b/deploy/cloudflare/test/lib.test.ts @@ -6,7 +6,9 @@ import { START_COMMAND, STORAGE_MOUNT_PATH, accessDeniedPage, + bootstrapGateMode, getCookie, + setupRequiredPage, bootingPage, bootingResponse, buildPaperclipEnv, @@ -120,6 +122,34 @@ describe("transient boot detection", () => { }); }); +describe("bootstrapGateMode", () => { + it("fails closed when no token is configured", () => { + expect(bootstrapGateMode({})).toBe("setup"); + expect(bootstrapGateMode({ token: undefined })).toBe("setup"); + }); + + it("treats an empty-string token as unconfigured, never open", () => { + expect(bootstrapGateMode({ token: "" })).toBe("setup"); + }); + + it("requires the token when one is configured", () => { + expect(bootstrapGateMode({ token: "s3cret" })).toBe("token"); + }); + + it("only opens on the explicit literal opt-out", () => { + expect(bootstrapGateMode({ disableGate: "true" })).toBe("open"); + expect(bootstrapGateMode({ token: "s3cret", disableGate: "true" })).toBe("open"); + expect(bootstrapGateMode({ disableGate: "TRUE" })).toBe("setup"); + expect(bootstrapGateMode({ disableGate: "1" })).toBe("setup"); + }); + + it("setup page tells the operator how to configure the gate", () => { + const html = setupRequiredPage(); + expect(html).toContain("BOOTSTRAP_TOKEN"); + expect(html).toContain("DISABLE_BOOTSTRAP_GATE"); + }); +}); + describe("bootstrap gate helpers", () => { it("extracts a single cookie value", () => { expect(getCookie("a=1; paperclip_bootstrap=tok; b=2", "paperclip_bootstrap")).toBe("tok"); diff --git a/deploy/cloudflare/wrangler.jsonc b/deploy/cloudflare/wrangler.jsonc index fb86712dca..9b5fc7ded0 100644 --- a/deploy/cloudflare/wrangler.jsonc +++ b/deploy/cloudflare/wrangler.jsonc @@ -37,7 +37,12 @@ "PAPERCLIP_DEPLOYMENT_MODE": "authenticated", "PAPERCLIP_DEPLOYMENT_EXPOSURE": "private", // Leave empty to default to the request origin (your workers.dev URL). - "PAPERCLIP_PUBLIC_URL": "" + "PAPERCLIP_PUBLIC_URL": "", + // The deployment starts fail-closed: it serves nothing until you set the + // BOOTSTRAP_TOKEN secret (protects the unclaimed operator invite). After + // claiming the operator account, flip this to "true" to open the login + // page to your team — Paperclip's own auth takes over from there. + "DISABLE_BOOTSTRAP_GATE": "false" }, "observability": { "enabled": true } } diff --git a/docs/deploy/cloudflare.md b/docs/deploy/cloudflare.md index 68fb197a74..c871b24e47 100644 --- a/docs/deploy/cloudflare.md +++ b/docs/deploy/cloudflare.md @@ -69,13 +69,12 @@ Notes: cd deploy/cloudflare pnpm install npx wrangler login - -# strongly recommended: gate the deployment until you claim the operator -# account — any value you choose, e.g. `openssl rand -hex 16` -npx wrangler secret put BOOTSTRAP_TOKEN - npx wrangler deploy +# required before anything is served: gate the deployment until you claim +# the operator account — any value you choose, e.g. `openssl rand -hex 16` +npx wrangler secret put BOOTSTRAP_TOKEN + # optional: give in-container agents an API key npx wrangler secret put ANTHROPIC_API_KEY ``` @@ -88,18 +87,14 @@ up. Paperclip boots in `authenticated` mode with a pending bootstrap invite, and **the first visitor to reach the app can claim the operator account**. The -`BOOTSTRAP_TOKEN` gate exists to make sure that visitor is you: without the -token (query param once; cookie afterwards), the Worker serves a 401 and -nothing reaches Paperclip. After you claim the account, remove the gate so -your team can reach the login page: +deployment is therefore **fail-closed**: until `BOOTSTRAP_TOKEN` is set, the +Worker serves only a setup page, and with the token set, requests must +present it (query param once; cookie afterwards) or receive a 401 — nothing +reaches Paperclip either way. -```sh -npx wrangler secret delete BOOTSTRAP_TOKEN -``` - -If you skip the token, deploy and claim the account immediately — an -unclaimed invite on a public `workers.dev` URL is claimable by anyone who -finds it. +After you claim the operator account, open the deployment to your team by +setting `DISABLE_BOOTSTRAP_GATE` to `"true"` in `wrangler.jsonc` and +redeploying — from that point Paperclip's own login protects everything. ## Configuration @@ -110,7 +105,8 @@ Set via `vars` in `wrangler.jsonc` or `wrangler secret put`: | `PAPERCLIP_PUBLIC_URL` | var | request origin | Public URL Paperclip advertises | | `PAPERCLIP_DEPLOYMENT_MODE` | var | `authenticated` | See [Deployment Modes](/deploy/deployment-modes) | | `PAPERCLIP_DEPLOYMENT_EXPOSURE` | var | `private` | Embedded Postgres currently requires `private`; use `public` only with an external `DATABASE_URL` | -| `BOOTSTRAP_TOKEN` | secret | — | Gates all access until the operator account is claimed (see Deploy) | +| `BOOTSTRAP_TOKEN` | secret | — (required) | Fail-closed gate: nothing is served until set (see Deploy) | +| `DISABLE_BOOTSTRAP_GATE` | var | `"false"` | Set `"true"` after claiming the operator account to open team logins | | `ANTHROPIC_API_KEY` | secret | — | Forwarded to in-container agent CLIs | | `DATABASE_URL` | secret | — | External Postgres (strongly recommended, see above) | | `ARTIFACTS` | R2 binding | — | Durable attachment storage (see above) |