diff --git a/deploy/cloudflare/src/index.ts b/deploy/cloudflare/src/index.ts index 0f0bf1df0d..02d40abcf3 100644 --- a/deploy/cloudflare/src/index.ts +++ b/deploy/cloudflare/src/index.ts @@ -26,7 +26,10 @@ import { bootingResponse, bootstrapGateMode, buildPaperclipEnv, + decodeCookieValue, + exceedsRequestSizeLimit, getCookie, + isOriginAllowed, setupRequiredPage, isMountAlreadyInUse, isPaperclipRunning, @@ -68,6 +71,12 @@ interface Env { * protects everything from then on. */ DISABLE_BOOTSTRAP_GATE?: string; + /** + * Comma-separated extra origins permitted to send cross-origin requests. + * Unset means same-origin only. Requests with no Origin header (CLI, agents, + * health checks) are always allowed — see isOriginAllowed. + */ + ALLOWED_ORIGINS?: string; } /** @@ -135,7 +144,8 @@ async function enforceBootstrapGate(request: Request, env: Env, url: URL): Promi } const cookie = getCookie(request.headers.get("Cookie"), BOOTSTRAP_COOKIE); - if (cookie !== undefined && (await tokensMatch(decodeURIComponent(cookie), env.BOOTSTRAP_TOKEN!))) { + const decoded = cookie === undefined ? undefined : decodeCookieValue(cookie); + if (decoded !== undefined && (await tokensMatch(decoded, env.BOOTSTRAP_TOKEN!))) { return null; } @@ -190,6 +200,15 @@ export default { const sandbox = getSandbox(env.Sandbox, SANDBOX_ID); const url = new URL(request.url); + // Cheap rejections first, before the gate does any crypto or the sandbox + // is touched at all. + if (exceedsRequestSizeLimit(request.headers)) { + return new Response("Request body too large", { status: 413 }); + } + if (!isOriginAllowed(request.headers.get("Origin"), url.origin, env.ALLOWED_ORIGINS)) { + return new Response("Origin not allowed", { status: 403 }); + } + const denied = await enforceBootstrapGate(request, env, url); if (denied) return denied; diff --git a/deploy/cloudflare/src/lib.ts b/deploy/cloudflare/src/lib.ts index d9eb573948..36f97be268 100644 --- a/deploy/cloudflare/src/lib.ts +++ b/deploy/cloudflare/src/lib.ts @@ -90,6 +90,67 @@ export function getCookie(cookieHeader: string | null, name: string): string | u return undefined; } +/** + * decodeURIComponent throws URIError on a malformed escape ("%ZZ"), and the + * cookie is fully client-controlled. Raw `decodeURIComponent` in the gate meant + * one bad cookie produced an unhandled Worker exception on every request from + * that client until they cleared it. A cookie that cannot be decoded simply is + * not a valid token, so treat it as absent. + */ +export function decodeCookieValue(value: string): string | undefined { + try { + return decodeURIComponent(value); + } catch { + return undefined; + } +} + +/** + * Largest request body proxied into the sandbox, in bytes. Attachments go + * through this Worker, so the cap is generous — it exists to stop a single + * request exhausting container memory, not to police normal uploads. + */ +export const MAX_REQUEST_BYTES = 100 * 1024 * 1024; + +/** + * Reject an oversized body before it reaches the container. Only Content-Length + * is checked: a chunked upload without one is passed through, because buffering + * it here to measure it would itself be the resource exhaustion this prevents. + */ +export function exceedsRequestSizeLimit( + headers: Headers, + limit: number = MAX_REQUEST_BYTES, +): boolean { + const raw = headers.get("Content-Length"); + if (raw === null) return false; + const length = Number(raw); + return Number.isFinite(length) && length > limit; +} + +/** + * Origin allowlist for state-changing cross-origin requests. Empty or unset + * means same-origin-only, which is the safe default; the deployment is a single + * origin, so no browser client legitimately posts from anywhere else. + * + * Requests with no Origin header are allowed: non-browser clients (the CLI, + * agents, health checks) never send one, and the bootstrap gate plus + * Paperclip's own auth are what actually authenticate them. + */ +export function isOriginAllowed( + origin: string | null, + selfOrigin: string, + allowlist?: string, +): boolean { + if (origin === null) return true; + if (origin === selfOrigin) return true; + if (!allowlist) return false; + return allowlist + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) + .includes(origin); +} + /** * Access-gate decision, fail-closed by default: * - "open" — operator explicitly disabled the gate (post-claim state) @@ -118,7 +179,7 @@ export function setupRequiredPage(): string { :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} + main{width:min(100%,60ch);padding:clamp(1rem,4vw,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} @@ -152,7 +213,7 @@ export function accessDeniedPage(): string { :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:34rem;padding:2rem} + main{width:min(100%,60ch);padding:clamp(1rem,4vw,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} diff --git a/deploy/cloudflare/test/lib.test.ts b/deploy/cloudflare/test/lib.test.ts index ad4d4b1246..597bd2a5bf 100644 --- a/deploy/cloudflare/test/lib.test.ts +++ b/deploy/cloudflare/test/lib.test.ts @@ -6,8 +6,12 @@ import { START_COMMAND, STORAGE_MOUNT_PATH, accessDeniedPage, + MAX_REQUEST_BYTES, bootstrapGateMode, + decodeCookieValue, + exceedsRequestSizeLimit, getCookie, + isOriginAllowed, setupRequiredPage, bootingPage, bootingResponse, @@ -162,6 +166,39 @@ describe("bootstrap gate helpers", () => { expect(getCookie("xpaperclip_bootstrap=evil", "paperclip_bootstrap")).toBeUndefined(); }); + it("treats an undecodable cookie as absent rather than throwing", () => { + // decodeURIComponent("%ZZ") throws URIError, and the gate runs before the + // Worker's try/catch — so a raw decode turned one malformed client cookie + // into an unhandled exception on every subsequent request from that client. + expect(() => decodeCookieValue("%ZZ")).not.toThrow(); + expect(decodeCookieValue("%ZZ")).toBeUndefined(); + expect(decodeCookieValue("%E0%A4%A")).toBeUndefined(); + expect(decodeCookieValue("plain")).toBe("plain"); + expect(decodeCookieValue("a%20b")).toBe("a b"); + }); + + it("rejects only bodies declaring more than the cap", () => { + const h = (v?: string) => new Headers(v === undefined ? {} : { "Content-Length": v }); + expect(exceedsRequestSizeLimit(h())).toBe(false); + expect(exceedsRequestSizeLimit(h(String(MAX_REQUEST_BYTES)))).toBe(false); + expect(exceedsRequestSizeLimit(h(String(MAX_REQUEST_BYTES + 1)))).toBe(true); + // A non-numeric Content-Length is not evidence of an oversized body. + expect(exceedsRequestSizeLimit(h("not-a-number"))).toBe(false); + }); + + it("allows same-origin and header-less callers, blocks other origins", () => { + const self = "https://paperclip.example.workers.dev"; + // CLI, agents and health checks send no Origin at all. + expect(isOriginAllowed(null, self)).toBe(true); + expect(isOriginAllowed(self, self)).toBe(true); + expect(isOriginAllowed("https://evil.example", self)).toBe(false); + expect(isOriginAllowed("https://ok.example", self, "https://ok.example")).toBe(true); + expect(isOriginAllowed("https://ok.example", self, " https://a.test , https://ok.example ")).toBe( + true, + ); + expect(isOriginAllowed("https://evil.example", self, "https://ok.example")).toBe(false); + }); + it("access-denied page names the param and secret", () => { const html = accessDeniedPage(); expect(html).toContain(BOOTSTRAP_PARAM); diff --git a/deploy/cloudflare/wrangler.jsonc b/deploy/cloudflare/wrangler.jsonc index 9b5fc7ded0..309a523553 100644 --- a/deploy/cloudflare/wrangler.jsonc +++ b/deploy/cloudflare/wrangler.jsonc @@ -2,7 +2,7 @@ "$schema": "node_modules/wrangler/config-schema.json", "name": "paperclip-sandbox", "main": "src/index.ts", - "compatibility_date": "2026-07-01", + "compatibility_date": "2026-08-29", "compatibility_flags": ["nodejs_compat"], "containers": [ {