fix(ui): fail closed on unreadable theme payloads

This commit is contained in:
Val Alexander
2026-04-24 20:31:48 -05:00
parent 450825c0f1
commit 6ff13a1b33
2 changed files with 48 additions and 29 deletions

View File

@@ -72,13 +72,26 @@ function createImportedTheme() {
function createResponse(
body: string,
options: { headers?: HeadersInit; status?: number; url?: string } = {},
options: {
body?: ReadableStream<Uint8Array> | 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",

View File

@@ -480,36 +480,32 @@ async function readResponseTextWithLimit(response: Response): Promise<string> {
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<unknown> {