diff --git a/docs/http-contract.md b/docs/http-contract.md index b06217e..6b9b9f5 100644 --- a/docs/http-contract.md +++ b/docs/http-contract.md @@ -18,7 +18,8 @@ signature and dimension checks; animated files are refused. Redirects and privat are refused, and visitor or Gateway credentials are never forwarded. The ticket is not the visitor bearer and does not reveal the source URL; responses are not cached. Node and PHP pin a public DNS address. Cloudflare Workers rely on their platform network policy and conservatively refuse sources -sharing the endpoint's last two hostname labels. +sharing the endpoint's last two hostname labels unless an explicitly configured public Assets or +public service binding handles the approved directory; bindings bypass the hostname's security rules. ## `GET {base}/config` diff --git a/packages/dewee-webchat/README.md b/packages/dewee-webchat/README.md index 28ba9d2..a02d48c 100644 --- a/packages/dewee-webchat/README.md +++ b/packages/dewee-webchat/README.md @@ -217,7 +217,30 @@ Only exact same-origin paths in both lists use the binding. This reads the Asset **bypassing Worker middleware, Cloudflare Access, and cookie-based authorization**. Every file under the configured prefixes must be public to any valid chat visitor; keep private files elsewhere, inspect the build output, and exclude sensitive files using `.assetsignore`. The URL query is not -passed to the binding. Sibling domains still require a separate approved image domain. +passed to the binding. + +For a sibling hostname in the same zone, a host can instead configure a service binding to a Worker +that serves public images from the listed directories: + +```ts +createDeweeHandler({ + // ... other handler options + imageSources: ["https://images.example.com/products/"], + cloudflarePublicImageServices: [ + { id: "catalog-v1", binding: env.PUBLIC_IMAGES, prefixes: ["https://images.example.com/products/"] }, + ], +}); +``` + +Both lists must allow the source. Each service needs a unique ID and non-overlapping prefixes; change +the ID whenever you repoint the binding so existing short-lived tickets cannot reach the new service. +The service ID is visible in the image ticket, but the source URL remains encrypted. The binding +calls the configured Worker directly, bypassing the +source hostname's Cloudflare security rules. Every configured prefix must be public to all chat +visitors; do not bind private services or paths. The Worker receives a GET with the approved path on +`https://public-images.invalid`, without the original query, cookies or authorization headers. It +must serve that path without relying on its hostname or a signed query string. A missing or invalid +response fails closed and never falls back to a network request. Files are limited to 2 MiB and 4096 pixels per edge, and responses are not cached. Animated GIF/PNG/WebP are refused. Raw HTML, diff --git a/packages/dewee-webchat/src/server/handler.ts b/packages/dewee-webchat/src/server/handler.ts index 1eb9ae7..9f97596 100644 --- a/packages/dewee-webchat/src/server/handler.ts +++ b/packages/dewee-webchat/src/server/handler.ts @@ -214,6 +214,8 @@ export interface DeweeHandlerOptions { credentialRequests?: { formOrigin: string }; /** Explicitly public directories in this Worker's Assets binding. Bypasses site middleware. */ cloudflarePublicImageAssets?: { binding: { fetch(request: Request): Promise }; prefixes: string[] }; + /** Public-only service bindings for approved HTTPS image directory prefixes. Bypasses host security rules. */ + cloudflarePublicImageServices?: Array<{ id: string; binding: { fetch(request: Request): Promise }; prefixes: string[] }>; /** * Legacy gateway transport: let visitors bring their own gateway URL + token (sent per request, never stored). * Meant for playgrounds. `allowedHosts` restricts which gateways may be targeted. @@ -345,6 +347,31 @@ export function createDeweeHandler(options: DeweeHandlerOptions): DeweeHandler { throw new Error("[dewee-webchat] Public image asset prefixes must be explicit absolute directory paths"); } const assetPrefixes = publicAssets?.prefixes ?? []; + if (o.cloudflarePublicImageServices !== undefined && (!Array.isArray(o.cloudflarePublicImageServices) || o.cloudflarePublicImageServices.length > 16)) { + throw new Error("[dewee-webchat] Public image services must be an array"); + } + const publicServices = (o.cloudflarePublicImageServices ?? []).map((service) => { + if (!service || typeof service.id !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(service.id) || typeof service.binding?.fetch !== "function" || !Array.isArray(service.prefixes) || !service.prefixes.length) { + throw new Error("[dewee-webchat] Public image services require a binding and directory prefixes"); + } + const prefixes = service.prefixes.map((raw) => { + let url: URL; + if (typeof raw !== "string" || !/^https:\/\/[^/?#]+\/(?:[A-Za-z0-9_-]+\/)+$/.test(raw)) { + throw new Error("[dewee-webchat] Invalid public image service prefix"); + } + try { url = new URL(raw); } catch { throw new Error("[dewee-webchat] Invalid public image service prefix"); } + if (url.protocol !== "https:" || url.username || url.password || url.port || url.search || url.hash || !/^\/(?:[A-Za-z0-9_-]+\/)+$/.test(url.pathname)) { + throw new Error("[dewee-webchat] Public image service prefixes must be HTTPS origins with explicit directory paths"); + } + return url; + }); + return { id: service.id, binding: service.binding, prefixes }; + }); + const servicePrefixes = publicServices.flatMap((service) => service.prefixes); + if (new Set(publicServices.map((service) => service.id)).size !== publicServices.length || servicePrefixes.some((prefix, index) => servicePrefixes.slice(index + 1).some((other) => prefix.origin === other.origin && (prefix.pathname.startsWith(other.pathname) || other.pathname.startsWith(prefix.pathname))))) { + throw new Error("[dewee-webchat] Public image service ids and directory prefixes must not overlap"); + } + const serviceFor = (source: URL) => publicServices.find((service) => service.prefixes.some((prefix) => prefix.origin === source.origin && source.pathname.startsWith(prefix.pathname))); let activeImages = 0; const imageNamespace = `${o.ccp?.chatUrl ?? o.gatewayUrl ?? ""}\n${o.ccp?.integrationId ?? o.tenantId ?? ""}`; if (o.allowedHeaders !== undefined && (!Array.isArray(o.allowedHeaders) || o.allowedHeaders.some((name) => typeof name !== "string" || !/^[A-Za-z0-9-]+$/.test(name)))) { @@ -1419,30 +1446,39 @@ export function createDeweeHandler(options: DeweeHandlerOptions): DeweeHandler { try { parsed = JSON.parse(body); } catch { return fail(req, 400, "Invalid image", "invalid_request"); } const raw = (parsed as { url?: unknown } | null)?.url; const worker = globalThis.navigator?.userAgent === "Cloudflare-Workers"; - const source = typeof raw === "string" ? allowedImageSource(raw, imageSources, new URL(req.url), worker, worker ? assetPrefixes : []) : null; + const source = typeof raw === "string" ? allowedImageSource(raw, imageSources, new URL(req.url), worker, worker ? assetPrefixes : [], worker ? servicePrefixes : []) : null; if (!source) return fail(req, 403, "Image not allowed", "forbidden_image"); const endpoint = new URL(req.url); const asset = publicAssets && source.origin === endpoint.origin && assetPrefixes.some((prefix) => source.pathname.startsWith(prefix)); - const namespace = asset ? `${imageNamespace}\nassets:${endpoint.origin}` : imageNamespace; - return json(req, { ticket: await issueImageTicket(secret!, namespace, claims.v, source.href) }); + const service = asset ? undefined : serviceFor(source); + const namespace = asset ? `${imageNamespace}\nassets:${endpoint.origin}` : service ? `${imageNamespace}\nservice:${endpoint.origin}:${service.id}` : imageNamespace; + const ticket = await issueImageTicket(secret!, namespace, claims.v, source.href); + return json(req, { ticket: service ? `s.${service.id}.${ticket}` : ticket }); } if (req.method === "GET" && action === "image" && imageSources.length) { const ticket = new URL(req.url).searchParams.get("ticket") ?? ""; const endpoint = new URL(req.url); - const assetClaims = await verifyImageTicket(secret!, `${imageNamespace}\nassets:${endpoint.origin}`, ticket); - const claims = assetClaims ?? await verifyImageTicket(secret!, imageNamespace, ticket); + const tagged = /^s\.([A-Za-z0-9_-]{1,64})\.([A-Za-z0-9_-]{1,8192})$/.exec(ticket); + const serviceId = tagged?.[1]; + if (serviceId && !publicServices.some((service) => service.id === serviceId)) return fail(req, 401, "Image ticket expired", "unauthorized"); + const serviceClaims = tagged ? await verifyImageTicket(secret!, `${imageNamespace}\nservice:${endpoint.origin}:${serviceId}`, tagged[2]) : null; + const assetClaims = tagged ? null : await verifyImageTicket(secret!, `${imageNamespace}\nassets:${endpoint.origin}`, ticket); + const claims = tagged ? serviceClaims : assetClaims ?? await verifyImageTicket(secret!, imageNamespace, ticket); if (!claims) return fail(req, 401, "Image ticket expired", "unauthorized"); const worker = globalThis.navigator?.userAgent === "Cloudflare-Workers"; - const source = allowedImageSource(claims.source, imageSources, endpoint, worker, worker ? assetPrefixes : []); + const source = allowedImageSource(claims.source, imageSources, endpoint, worker, worker ? assetPrefixes : [], worker ? servicePrefixes : []); if (!source) return fail(req, 403, "Image not allowed", "forbidden_image"); const asset = publicAssets && source.origin === endpoint.origin && assetPrefixes.some((prefix) => source.pathname.startsWith(prefix)); if (Boolean(asset) !== Boolean(assetClaims)) return fail(req, 403, "Image not allowed", "forbidden_image"); + const service = asset ? undefined : serviceFor(source); + if (service?.id !== serviceId) return fail(req, 403, "Image not allowed", "forbidden_image"); if (limiter && !(await limiter(`image:${claims.visitor}`, "preauth"))) return fail(req, 429, "Too many requests, try again later", "rate_limited"); if (activeImages >= 8) return fail(req, 429, "Too many requests, try again later", "rate_limited"); activeImages++; try { const assetFetch: typeof fetch = (input, init) => publicAssets!.binding.fetch(new Request(new URL(new URL(String(input)).pathname, "https://assets.invalid"), init)); - const result = await fetchRasterImage(source.href, asset ? assetFetch : worker ? globalThis.fetch : publicImageFetchNode, worker ? "manual" : "error"); + const serviceFetch: typeof fetch = (input, init) => service!.binding.fetch(new Request(new URL(new URL(String(input)).pathname, "https://public-images.invalid"), init)); + const result = await fetchRasterImage(source.href, asset ? assetFetch : service ? serviceFetch : worker ? globalThis.fetch : publicImageFetchNode, worker || service ? "manual" : "error"); return result ?? fail(req, 502, "Image unavailable", "image_unavailable"); } finally { activeImages--; } } diff --git a/packages/dewee-webchat/src/server/image-proxy.ts b/packages/dewee-webchat/src/server/image-proxy.ts index 63fa0c6..821910e 100644 --- a/packages/dewee-webchat/src/server/image-proxy.ts +++ b/packages/dewee-webchat/src/server/image-proxy.ts @@ -49,7 +49,7 @@ export async function verifyImageTicket(secret: string, namespace: string, ticke } /** Reject ambiguous path encodings before comparing against explicitly configured directory prefixes. */ -export function allowedImageSource(raw: string, prefixes: URL[], endpoint: URL, cloudflare: boolean, publicAssets: string[] = []): URL | null { +export function allowedImageSource(raw: string, prefixes: URL[], endpoint: URL, cloudflare: boolean, publicAssets: string[] = [], publicServices: URL[] = []): URL | null { try { if (!raw || raw.length > 4096 || /[\u0000-\u0020\u007f\\]/.test(raw)) return null; const match = /^https:\/\/[^/?#]+([^?#]*)/i.exec(raw); @@ -65,7 +65,8 @@ export function allowedImageSource(raw: string, prefixes: URL[], endpoint: URL, const suffix = endpoint.hostname.split(".").slice(-2).join("."); const sameZone = source.hostname === suffix || source.hostname.endsWith(`.${suffix}`); const isPublicAsset = source.origin === endpoint.origin && publicAssets.some((prefix) => source.pathname.startsWith(prefix)); - if (sameZone && !isPublicAsset) return null; + const isPublicService = publicServices.some((prefix) => prefix.origin === source.origin && source.pathname.startsWith(prefix.pathname)); + if (sameZone && !isPublicAsset && !isPublicService) return null; } return prefixes.some((prefix) => prefix.origin === source.origin && source.pathname.startsWith(prefix.pathname)) ? source : null; } catch { return null; } diff --git a/packages/dewee-webchat/test/server-image-route.test.ts b/packages/dewee-webchat/test/server-image-route.test.ts index c507c03..0e06745 100644 --- a/packages/dewee-webchat/test/server-image-route.test.ts +++ b/packages/dewee-webchat/test/server-image-route.test.ts @@ -130,6 +130,65 @@ describe("image proxy route", () => { } }); + it("uses an explicitly public service for an allowed sibling origin without forwarding credentials", async () => { + const workerFetch = vi.fn(); + const binding = { fetch: vi.fn().mockImplementation(async () => png()) }; + vi.stubGlobal("navigator", { userAgent: "Cloudflare-Workers" }); + vi.stubGlobal("fetch", workerFetch); + const options = { gatewayUrl: "https://gateway.example", token: "server-only", secret, agents: ["support"], rateLimit: false as const, + imageSources: ["https://cdn.chat.example/public/", "https://cdn.chat.example/private/"], + cloudflarePublicImageServices: [{ id: "catalog-v1", binding, prefixes: ["https://cdn.chat.example/public/"] }] }; + const h = createDeweeHandler(options); + expect((await h(await ticketRequest("https://cdn.chat.example/private/a.png"))).status).toBe(403); + expect((await h(await ticketRequest("https://other.chat.example/public/a.png"))).status).toBe(403); + expect((await h(await ticketRequest("https://cdn.chat.example/public/%2fprivate/a.png"))).status).toBe(403); + const legacy = await issueImageTicket(secret, "https://gateway.example\n", "visitor_0123456789", "https://cdn.chat.example/public/a.png"); + expect((await h(imageRequest(legacy))).status).toBe(403); + const { ticket } = await (await h(await ticketRequest("https://cdn.chat.example/public/a.png?secret=discarded"))).json(); + expect(ticket.startsWith("s.catalog-v1.")).toBe(true); + expect((await h(new Request(`https://other.example/api/dewee/image?ticket=${ticket}`))).status).toBe(401); + expect((await createDeweeHandler({ ...options, cloudflarePublicImageServices: [] })(imageRequest(ticket))).status).toBe(401); + expect((await createDeweeHandler({ ...options, cloudflarePublicImageServices: [{ ...options.cloudflarePublicImageServices[0], id: "catalog-v2" }] })(imageRequest(ticket))).status).toBe(401); + expect((await createDeweeHandler({ ...options, imageSources: ["https://cdn.chat.example/private/"] })(imageRequest(ticket))).status).toBe(403); + expect((await h(imageRequest(ticket))).status).toBe(200); + const request = binding.fetch.mock.calls[0][0] as Request; + expect(request.url).toBe("https://public-images.invalid/public/a.png"); + expect(request.method).toBe("GET"); + expect(request.headers.get("cookie")).toBeNull(); + expect(request.headers.get("authorization")).toBeNull(); + expect(workerFetch).not.toHaveBeenCalled(); + expect(network).not.toHaveBeenCalled(); + binding.fetch.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: "https://evil.example" } })); + expect((await h(imageRequest(ticket))).status).toBe(502); + binding.fetch.mockResolvedValueOnce(new Response("not an image", { headers: { "content-type": "image/png" } })); + expect((await h(imageRequest(ticket))).status).toBe(502); + }); + + it("rejects malformed or broad public service prefixes", () => { + for (const prefix of ["https://cdn.example/", "https://cdn.example/public/../private/", "https://cdn.example/public/%2f/", "http://cdn.example/public/", "https://cdn.example/public/?secret=x"]) { + expect(() => createDeweeHandler({ gatewayUrl: "https://gateway.example", token: "server-only", secret, + cloudflarePublicImageServices: [{ id: "public", binding: { fetch: async () => png() }, prefixes: [prefix] }] })).toThrow(); + } + const binding = { fetch: async () => png() }; + expect(() => createDeweeHandler({ gatewayUrl: "https://gateway.example", token: "server-only", secret, + cloudflarePublicImageServices: [{ id: "a", binding, prefixes: ["https://cdn.example/public/"] }, { id: "b", binding, prefixes: ["https://cdn.example/public/products/"] }] })).toThrow(); + expect(() => createDeweeHandler({ gatewayUrl: "https://gateway.example", token: "server-only", secret, + cloudflarePublicImageServices: [{ id: "a", binding, prefixes: ["https://cdn.example/public/"] }, { id: "a", binding, prefixes: ["https://cdn.example/other/"] }] })).toThrow(); + }); + + it("checks only the named service namespace for an invalid ticket", async () => { + const binding = { fetch: async () => png() }; + const services = Array.from({ length: 16 }, (_, i) => ({ id: `service-${i}`, binding, prefixes: [`https://cdn.example/path-${i}/`] })); + const h = createDeweeHandler({ gatewayUrl: "https://gateway.example", token: "server-only", secret, + imageSources: ["https://cdn.example/path-0/"], cloudflarePublicImageServices: services }); + const decrypt = vi.spyOn(crypto.subtle, "decrypt"); + try { + const invalid = Buffer.alloc(32).toString("base64url"); + expect((await h(imageRequest(`s.service-0.${invalid}`))).status).toBe(401); + expect(decrypt).toHaveBeenCalledTimes(1); + } finally { decrypt.mockRestore(); } + }); + it("uses an explicit asset binding even without a Workers user agent", async () => { const binding = { fetch: vi.fn().mockImplementation(async () => png()) }; const h = createDeweeHandler({ gatewayUrl: "https://gateway.example", token: "server-only", secret, agents: ["support"], rateLimit: false,