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:
parent
5ea8a82157
commit
86eb6d23a6
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}=<your token></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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue