fix(deploy): make bootstrap gate fail-closed by default

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
This commit is contained in:
Daniel Bodnar 2026-07-23 03:11:06 -05:00
parent 5ea8a82157
commit 86eb6d23a6
No known key found for this signature in database
6 changed files with 129 additions and 28 deletions

View File

@ -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

View File

@ -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<boolean
}
/**
* Bootstrap gate: when BOOTSTRAP_TOKEN is set, only requests presenting it
* Bootstrap gate, fail-closed: with no token configured the deployment
* serves only the setup page; with a token, only requests presenting it
* (query param once, cookie afterwards) reach Paperclip. Returns null when
* the request may proceed, otherwise the response to serve.
*/
async function enforceBootstrapGate(request: Request, env: Env, url: URL): Promise<Response | null> {
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;
}

View File

@ -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 `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Paperclip setup required</title>
<style>
:root{color-scheme:light dark}
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
font-family:ui-sans-serif,system-ui,sans-serif;background:#0d1017;color:#e6e6e6}
main{max-width:36rem;padding:2rem}
h1{font-size:1.1rem;font-weight:600;margin:0 0 .5rem}
p{margin:.35rem 0;color:#9aa4bf;font-size:.9rem}
code{color:#7dd3fc}
</style>
</head>
<body>
<main>
<h1>Setup required</h1>
<p>This Paperclip deployment starts <strong>locked</strong> so that nobody
else can claim the operator account before you do.</p>
<p>Set a bootstrap token, then open this URL with
<code>?${BOOTSTRAP_PARAM}=&lt;your token&gt;</code>:</p>
<p><code>npx wrangler secret put BOOTSTRAP_TOKEN</code></p>
<p>After you claim the operator account you can open the deployment to your
team by setting <code>DISABLE_BOOTSTRAP_GATE</code> to <code>"true"</code>
in <code>wrangler.jsonc</code> and redeploying.</p>
</main>
</body>
</html>`;
}
/** 401 page shown while the deployment is gated by BOOTSTRAP_TOKEN. */
export function accessDeniedPage(): string {
return `<!doctype html>

View File

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

View File

@ -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 }
}

View File

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