Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/http-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
25 changes: 24 additions & 1 deletion packages/dewee-webchat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
50 changes: 43 additions & 7 deletions packages/dewee-webchat/src/server/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> }; 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<Response> }; 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.
Expand Down Expand Up @@ -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)))) {
Expand Down Expand Up @@ -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--; }
}
Expand Down
5 changes: 3 additions & 2 deletions packages/dewee-webchat/src/server/image-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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; }
Expand Down
59 changes: 59 additions & 0 deletions packages/dewee-webchat/test/server-image-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading