import { env, WorkerEntrypoint } from "cloudflare:workers"; import { repoDb } from "../scoped-db"; import { siteScope } from "./store"; import { decryptSecret } from "../crypto"; import { isForgeHostname, objectKey, SCRATCH_LIMITS, SITE_LIMITS, sitesDomain } from "./config"; import { isBlockedOutbound, sanitizeSetCookie, stripReservedCookies } from "./naming"; import { meterSiteCpu } from "./metering"; import { ensureSecurityHeaders, isUpgrade } from "./harden"; import { loadManifest, plain, serveStatic } from "./static"; import type { SiteManifest, SiteMeta } from "./config"; const COMPATIBILITY_DATE = "2026-10-01"; const MAX_BUNDLE_BYTES = 10 * 1024 * 1024; type AssetProps = { deploymentId: string; spa: boolean }; type OutboundProps = { siteId: string }; /** `env.ASSETS` inside a dynamic site: static files of the same deployment, with no access to anything else. */ export class SiteAssets extends WorkerEntrypoint { override async fetch(request: Request): Promise { const manifest = await loadManifest(this.ctx.props.deploymentId); if (!manifest) return plain(503, "Deployment unavailable"); return serveStatic(request, manifest, this.ctx.props.spa); } } /** The only network path out of a claimed site's isolate: no forge, no sites, no private or loopback addresses. */ export class SiteOutbound extends WorkerEntrypoint { override async fetch(request: Request): Promise { const url = new URL(request.url); const blocked = [new URL(env.PUBLIC_URL).hostname, sitesDomain()].filter((host): host is string => Boolean(host)); if (isBlockedOutbound(url, blocked) || isForgeHostname(url.hostname)) { return plain(403, `Outbound request to ${url.hostname} is not allowed from sites`); } const headers = new Headers(request.headers); for (const name of [...headers.keys()]) { if (name.startsWith("cf-") || name.startsWith("x-forwarded-") || name === "x-real-ip") headers.delete(name); } headers.set("x-gitfor-site", this.ctx.props.siteId); return fetch(new Request(request, { headers, redirect: "manual" })); } } function moduleFor(path: string, bytes: ArrayBuffer): WorkerLoaderModule | string { const text = () => new TextDecoder().decode(bytes); if (path.endsWith(".wasm")) return { wasm: bytes }; if (path.endsWith(".json")) return { json: JSON.parse(text()) }; if (path.endsWith(".txt")) return { text: text() }; if (path.endsWith(".cjs")) return { cjs: text() }; return { js: text() }; } async function loadModules( worker: NonNullable, ): Promise> { const total = Object.values(worker).reduce((sum, [, size]) => sum + size, 0); if (total > MAX_BUNDLE_BYTES) throw new Error("Worker bundle exceeds the size limit"); const entries = await Promise.all( Object.entries(worker).map(async ([path, [sha]]) => { const object = await env.BUCKET.get(objectKey(sha)); if (!object) throw new Error(`Missing worker module ${path}`); return [path, moduleFor(path, await object.arrayBuffer())] as const; }), ); return Object.fromEntries(entries); } /** * Production deployments get every variable. Previews only get variables marked `preview`, and only when the * deployment was created by a repo admin or the admin opted the site in; otherwise a writer's branch would run with * the admin's production secrets (CI-A3). */ async function siteVariables(meta: SiteMeta, deploymentId: string): Promise> { const db = repoDb(siteScope({ id: meta.id, repo_id: meta.repoId })); const deployment = await db.first<{ environment: string; creator_admin: number }>( "SELECT environment, creator_admin FROM site_deployments WHERE id = ? AND site_id = ?", deploymentId, meta.id, ); if (!deployment) return {}; const preview = deployment.environment !== "production"; if (preview && deployment.creator_admin !== 1 && meta.previewEnv !== true) return {}; const rows = await db.all<{ name: string; value_ct: string; value_iv: string }>( `SELECT name, value_ct, value_iv FROM site_env WHERE site_id = ? AND (? = 0 OR preview = 1) LIMIT ?`, meta.id, preview ? 1 : 0, SITE_LIMITS.envEntries, ); const out: Record = {}; for (const row of rows) out[row.name] = await decryptSecret(env.SECRETS_KEY, row.value_ct, row.value_iv); return out; } /** Request as seen by user code: dispatcher cookies removed, the verified visitor identity added. */ function sanitizedRequest(request: Request, subject: string | null): Request { const headers = new Headers(request.headers); const cookie = stripReservedCookies(headers.get("cookie")); if (cookie) headers.set("cookie", cookie); else headers.delete("cookie"); headers.delete("x-gitfor-user"); if (subject) headers.set("x-gitfor-user", subject); return new Request(request, { headers }); } /** Host-only, non-reserved cookies only; see `sanitizeSetCookie`. */ function sanitizeCookies(response: Response, host: string): Response { const domain = sitesDomain(); // Without a configured sites domain, stay strict rather than trusting any host. const shared = domain === null || host.toLowerCase().endsWith(`.${domain}`); const cookies = response.headers.getSetCookie(); if (cookies.length === 0 || isUpgrade(response)) return response; const headers = new Headers(response.headers); headers.delete("set-cookie"); for (const cookie of cookies) { const safe = sanitizeSetCookie(cookie, host, shared); if (safe) headers.append("set-cookie", safe); } return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); } type TailProps = { siteId: string; ownerId: string | null; scratch: boolean; repoId: string | null }; /** Tail of every site isolate: meters CPU time per site for billing and the scratch free tier. */ export class SiteTail extends WorkerEntrypoint { override async tail(events: TraceItem[]): Promise { const cpuMs = events.reduce((sum, event) => sum + (event.cpuTime ?? 0), 0); if (cpuMs <= 0) return; const { siteId, ownerId, scratch, repoId } = this.ctx.props; await meterSiteCpu({ id: siteId, ownerId, scratch, repoId }, cpuMs); } } /** Runs a dynamic site in its own isolate; the loader caches it per deployment and env version. */ export async function runSiteWorker( request: Request, ctx: ExecutionContext, meta: SiteMeta, deploymentId: string, manifest: SiteManifest, subject: string | null, ): Promise { const worker = manifest.worker; if (!worker) return serveStatic(request, manifest, meta.spa, meta.access !== "public"); const limits = meta.scratch ? { cpuMs: SCRATCH_LIMITS.cpuMs, subRequests: SCRATCH_LIMITS.subRequests } : { cpuMs: SITE_LIMITS.cpuMs, subRequests: SITE_LIMITS.subRequests }; const stub = env.LOADER.get(`${deploymentId}:${meta.envVersion}:${meta.scratch ? "s" : "c"}`, async () => ({ compatibilityDate: COMPATIBILITY_DATE, compatibilityFlags: ["nodejs_compat"], mainModule: "index.js", modules: await loadModules(worker), env: { ...(meta.scratch ? {} : await siteVariables(meta, deploymentId)), ASSETS: ctx.exports.SiteAssets({ props: { deploymentId, spa: meta.spa } }), }, globalOutbound: meta.scratch ? null : ctx.exports.SiteOutbound({ props: { siteId: meta.id } }), tails: [ ctx.exports.SiteTail({ props: { siteId: meta.id, ownerId: meta.ownerId, scratch: meta.scratch, repoId: meta.repoId }, }), ], limits, })); try { const response = await stub.getEntrypoint(undefined, { limits }).fetch(sanitizedRequest(request, subject)); // Scratch responses get the stricter scratch headers in the dispatcher; claimed ones keep the baseline. return ensureSecurityHeaders(sanitizeCookies(response, new URL(request.url).hostname)); } catch (error) { console.warn("Site worker failed", meta.id, deploymentId, error); return plain(502, "The site's worker threw an exception"); } }