Harden the Cloudflare deployment Worker
Fixes a crash and closes three gaps in the request path. The bootstrap gate called decodeURIComponent directly on the cookie, which is entirely client-controlled, and the gate runs above the fetch handler's try/catch. A malformed escape such as "%ZZ" therefore threw an unhandled URIError, so one bad cookie produced a hard Worker exception on every subsequent request from that client until they cleared it. getCookie returns the raw value by design, so nothing upstream sanitised it. decodeCookieValue now treats an undecodable cookie as absent, with a regression test. A public endpoint fronting a paid model is supposed to ship size caps and an origin allowlist from the start: - exceedsRequestSizeLimit rejects declared bodies over 100MB with a 413. Only Content-Length is inspected; buffering a chunked body to measure it would be the exhaustion the check exists to prevent. - isOriginAllowed answers 403 for cross-origin requests, same-origin by default and extensible through ALLOWED_ORIGINS. Requests with no Origin header pass, because the CLI, agents and health checks never send one. Rate limiting is deliberately not included: the GA ratelimit binding needs a namespace_id and a limit/period policy, which are deployment decisions. Also bumps compatibility_date to the current date rather than an inherited one, and replaces the max-width page shells in the status pages with a fluid width:min(100%,60ch).
This commit is contained in:
parent
fffb701887
commit
7b8d152949
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue