From dada2ba332a8f08ae559157aae85695c9fde9403 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Sun, 8 Nov 2026 23:29:51 +0700 Subject: [PATCH] fix(images): accept service-binding tickets in widget --- .../dewee-webchat/src/widget/api-client.ts | 2 +- .../test/server-image-route.test.ts | 22 +++++++++++++++++++ .../test/widget-markdown-images.test.ts | 21 ++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/dewee-webchat/src/widget/api-client.ts b/packages/dewee-webchat/src/widget/api-client.ts index 23c5c2b..6f501d9 100644 --- a/packages/dewee-webchat/src/widget/api-client.ts +++ b/packages/dewee-webchat/src/widget/api-client.ts @@ -144,7 +144,7 @@ export class ApiClient { async imageUrl(source: string, signal?: AbortSignal): Promise { const res = await this.request(this.url("image-ticket"), { method: "POST", headers: await this.headers(true), body: JSON.stringify({ url: source }), redirect: "error", signal }); const data = await this.parse<{ ticket: string }>(res); - if (typeof data.ticket !== "string" || !/^[A-Za-z0-9_-]{1,8192}$/.test(data.ticket)) throw new Error("Invalid image ticket"); + if (typeof data.ticket !== "string" || !/^(?:[A-Za-z0-9_-]{1,8192}|s\.[A-Za-z0-9_-]{1,64}\.[A-Za-z0-9_-]{1,8192})$/.test(data.ticket)) throw new Error("Invalid image ticket"); return this.url("image", { ticket: data.ticket }); } diff --git a/packages/dewee-webchat/test/server-image-route.test.ts b/packages/dewee-webchat/test/server-image-route.test.ts index 0e06745..9072dd4 100644 --- a/packages/dewee-webchat/test/server-image-route.test.ts +++ b/packages/dewee-webchat/test/server-image-route.test.ts @@ -4,6 +4,7 @@ const network = vi.hoisted(() => vi.fn()); vi.mock("../src/server/public-image-fetch-node", () => ({ publicImageFetchNode: network })); import { createDeweeHandler, signVisitorToken } from "../src/server"; import { issueImageTicket, verifyImageTicket } from "../src/server/image-proxy"; +import { ApiClient } from "../src/widget/api-client"; const secret = "fixture-secret-at-least-32-bytes-long"; const handler = () => createDeweeHandler({ gatewayUrl: "https://gateway.example", token: "server-only", secret, agents: ["support"], rateLimit: false, imageSources: ["https://cdn.example/images/"] }); @@ -164,6 +165,27 @@ describe("image proxy route", () => { expect((await h(imageRequest(ticket))).status).toBe(502); }); + it("lets the browser client use the actual service-binding ticket issued by the handler", async () => { + vi.stubGlobal("navigator", { userAgent: "Cloudflare-Workers" }); + const binding = { fetch: vi.fn().mockImplementation(async () => png()) }; + const h = createDeweeHandler({ gatewayUrl: "https://gateway.example", token: "server-only", secret, agents: ["support"], rateLimit: false, + imageSources: ["https://cdn.chat.example/public/"], cloudflarePublicImageServices: [{ id: "catalog-v1", binding, prefixes: ["https://cdn.chat.example/public/"] }] }); + const client = new ApiClient({ endpoint: "https://chat.example/api/dewee", fetch: async (input, init) => { + const headers = new Headers(init?.headers); headers.set("authorization", `Bearer ${await token()}`); + return h(new Request(input, { ...init, headers })); + } }); + const url = await client.imageUrl("https://cdn.chat.example/public/a.png"); + expect((await h(new Request(url))).status).toBe(200); + expect(binding.fetch).toHaveBeenCalledOnce(); + }); + + it("refuses malformed service ticket tags before constructing an image URL", async () => { + for (const ticket of ["s..cipher", "s.a.b.extra", "s.a../cipher", "s.a.cipher?other=x", `s.${"x".repeat(65)}.cipher`, `s.a.${"x".repeat(8193)}`]) { + const client = new ApiClient({ endpoint: "https://chat.example/api/dewee", fetch: async () => new Response(JSON.stringify({ ticket }), { headers: { "content-type": "application/json" } }) }); + await expect(client.imageUrl("https://cdn.chat.example/public/a.png"), ticket.slice(0, 80)).rejects.toThrow("Invalid image ticket"); + } + }); + 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, diff --git a/packages/dewee-webchat/test/widget-markdown-images.test.ts b/packages/dewee-webchat/test/widget-markdown-images.test.ts index 7a759cb..1f9fe6a 100644 --- a/packages/dewee-webchat/test/widget-markdown-images.test.ts +++ b/packages/dewee-webchat/test/widget-markdown-images.test.ts @@ -15,6 +15,27 @@ beforeAll(() => defineDeweeChat()); afterEach(() => { document.body.replaceChildren(); localStorage.clear(); vi.unstubAllGlobals(); ViewportObserver.observers = []; }); describe.each(["inline", "floating"])("Markdown images in %s mode", (mode) => { + it("loads a service-binding image using its tagged capability ticket", async () => { + vi.stubGlobal("IntersectionObserver", ViewportObserver); + const json = (data: unknown) => new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } }); + vi.stubGlobal("fetch", vi.fn(async (url: string) => { + const path = new URL(url, "https://site.test").pathname; + if (path.endsWith("/config")) return json({ agents: [{ id: "support" }], defaultAgent: "support", maxMessageLength: 4000 }); + if (path.endsWith("/session")) return json({ visitorId: "visitor", token: "test", expiresAt: Date.now() + 86400000 }); + if (path.endsWith("/image-ticket")) return json({ ticket: "s.catalog-v1.encrypted_ticket" }); + if (path.endsWith("/history")) return json({ messages: [{ role: "assistant", content: image }] }); + return json({}); + })); + localStorage.setItem("dewee-webchat:conv:/api/dewee::support", "conversation_123"); + const el = document.createElement("dewee-chat") as DeweeChatElement; + el.setAttribute("endpoint", "/api/dewee"); el.setAttribute("mode", mode); + el.setAttribute("image-origins", '["https://shop.example"]'); + document.body.appendChild(el); if (mode === "floating") el.open(); + await vi.waitFor(() => expect(el.shadowRoot!.querySelector(".md img")).not.toBeNull()); + ViewportObserver.observers.at(-1)?.enter(); + await vi.waitFor(() => expect(el.shadowRoot!.querySelector(".md img")?.getAttribute("src")).toContain("ticket=s.catalog-v1.encrypted_ticket")); + }); + it("does not load an image repeatedly while a reply streams", async () => { vi.stubGlobal("IntersectionObserver", ViewportObserver); const json = (data: unknown) => new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } });