From 6ff13a1b332e2e25dc40d99f791a5f32a3871839 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 24 Apr 2026 20:31:48 -0500 Subject: [PATCH] fix(ui): fail closed on unreadable theme payloads --- ui/src/ui/custom-theme.test.ts | 27 ++++++++++++++++-- ui/src/ui/custom-theme.ts | 50 ++++++++++++++++------------------ 2 files changed, 48 insertions(+), 29 deletions(-) diff --git a/ui/src/ui/custom-theme.test.ts b/ui/src/ui/custom-theme.test.ts index 2c07eb86dc1..9a181966da4 100644 --- a/ui/src/ui/custom-theme.test.ts +++ b/ui/src/ui/custom-theme.test.ts @@ -72,13 +72,26 @@ function createImportedTheme() { function createResponse( body: string, - options: { headers?: HeadersInit; status?: number; url?: string } = {}, + options: { + body?: ReadableStream | null; + headers?: HeadersInit; + status?: number; + url?: string; + } = {}, ) { return { ok: (options.status ?? 200) >= 200 && (options.status ?? 200) < 300, status: options.status ?? 200, headers: new Headers(options.headers), - body: null, + body: + options.body === undefined + ? new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(body)); + controller.close(); + }, + }) + : options.body, text: vi.fn(async () => body), url: options.url ?? "", } as unknown as Response; @@ -144,6 +157,16 @@ describe("custom theme import helpers", () => { ).rejects.toThrow("too large"); }); + it("rejects tweakcn theme responses without a bounded body stream", async () => { + const response = createResponse(JSON.stringify(createTweakcnPayload()), { body: null }); + const fetchImpl = vi.fn(async () => response) as unknown as typeof fetch; + + await expect( + importCustomThemeFromUrl("https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z", fetchImpl), + ).rejects.toThrow("unreadable theme payload"); + expect(response.text).not.toHaveBeenCalled(); + }); + it("rejects redirected tweakcn import responses", async () => { const response = createResponse(JSON.stringify(createTweakcnPayload()), { url: "https://example.com/r/themes/cmlhfpjhw000004l4f4ax3m7z", diff --git a/ui/src/ui/custom-theme.ts b/ui/src/ui/custom-theme.ts index 6190e13adf0..40f05584fba 100644 --- a/ui/src/ui/custom-theme.ts +++ b/ui/src/ui/custom-theme.ts @@ -480,36 +480,32 @@ async function readResponseTextWithLimit(response: Response): Promise { throw new Error("tweakcn theme payload is too large."); } - if (response.body) { - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let bytes = 0; - let text = ""; - try { - while (true) { - const chunk = await reader.read(); - if (chunk.done) { - break; - } - bytes += chunk.value.byteLength; - if (bytes > MAX_TWEAKCN_THEME_BYTES) { - await reader.cancel().catch(() => undefined); - throw new Error("tweakcn theme payload is too large."); - } - text += decoder.decode(chunk.value, { stream: true }); - } - text += decoder.decode(); - return text; - } finally { - reader.releaseLock(); - } + if (!response.body) { + throw new Error("tweakcn returned an unreadable theme payload."); } - const text = await response.text(); - if (new TextEncoder().encode(text).byteLength > MAX_TWEAKCN_THEME_BYTES) { - throw new Error("tweakcn theme payload is too large."); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let bytes = 0; + let text = ""; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) { + break; + } + bytes += chunk.value.byteLength; + if (bytes > MAX_TWEAKCN_THEME_BYTES) { + await reader.cancel().catch(() => undefined); + throw new Error("tweakcn theme payload is too large."); + } + text += decoder.decode(chunk.value, { stream: true }); + } + text += decoder.decode(); + return text; + } finally { + reader.releaseLock(); } - return text; } async function readJsonResponseWithLimit(response: Response): Promise {