Skip to content

Commit 8a0d5e5

Browse files
committed
fix(web): bound ZIP image preview dimensions
1 parent 7e4ebb3 commit 8a0d5e5

3 files changed

Lines changed: 196 additions & 13 deletions

File tree

‎apps/web/src/components/files/ZipArchivePreview.test.tsx‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -219,13 +219,17 @@ describe("ZIP file browsing", () => {
219219
expect(renderer.root.findByType("pre").children).toEqual(["app.ts"]);
220220
});
221221

222-
it("shows extraction failures without losing the file tree", async () => {
223-
readFile.mockRejectedValueOnce(new Error("File preview supports files up to 10 MiB."));
222+
it.each([
223+
"File preview supports files up to 10 MiB.",
224+
"Image preview supports up to 16 million pixels and 8,192 pixels per side. Download the ZIP to open it.",
225+
"Cannot safely preview this image. Download the ZIP to open it.",
226+
])("shows %s without decoding or losing the file tree", async (message) => {
227+
readFile.mockRejectedValueOnce(new Error(message));
224228
await open();
225229
await select("photos/a.png");
226-
expect(renderer.root.findByProps({ role: "alert" }).children).toEqual([
227-
"File preview supports files up to 10 MiB.",
228-
]);
230+
expect(renderer.root.findByProps({ role: "alert" }).children).toEqual([message]);
231+
expect(renderer.root.findAllByType("img")).toHaveLength(0);
232+
expect(URL.createObjectURL).not.toHaveBeenCalled();
229233
await back();
230234
await select("src/app.ts");
231235
expect(renderer.root.findByType("pre").children).toEqual(["app.ts"]);

‎apps/web/src/lib/zipPreview.test.ts‎

Lines changed: 128 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ import { loadZipPreview, readZipPreviewResponse } from "./zipPreview";
55

66
const signal = () => new AbortController().signal;
77

8+
function png(width = 800, height = 600) {
9+
const bytes = new Uint8Array(24);
10+
bytes.set([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82]);
11+
const view = new DataView(bytes.buffer);
12+
view.setUint32(16, width);
13+
view.setUint32(20, height);
14+
return bytes;
15+
}
16+
817
async function archive(zip: JSZip) {
918
return new Blob([await zip.generateAsync({ type: "arraybuffer", compression: "DEFLATE" })]);
1019
}
@@ -183,19 +192,129 @@ describe("loadZipPreview", () => {
183192
]);
184193
});
185194

186-
it("reads the selected raster image into a typed blob", async () => {
187-
const zip = new JSZip().file("photo.jpg", new Uint8Array([0xff, 0xd8, 0xff]));
195+
it("checks the image bytes even when the extension declares a different image type", async () => {
196+
const zip = new JSZip().file("photo.jpg", png());
188197
const preview = await loadZipPreview(await archive(zip), signal());
189198
const image = await preview.readFile(preview.entries[0]!, signal());
190199

191200
expect(image.type).toBe("image/jpeg");
192-
expect(new Uint8Array(await image.arrayBuffer())).toEqual(new Uint8Array([0xff, 0xd8, 0xff]));
201+
expect(new Uint8Array(await image.arrayBuffer())).toEqual(png());
202+
});
203+
204+
it.each([
205+
["photo.png", png(10000, 10000)],
206+
["photo.jpg", png(4001, 4000)],
207+
["photo.webp", png(8193, 1)],
208+
["photo.jpg", new Uint8Array([255, 216, 255, 192, 0, 17, 8, 39, 16, 39, 16])],
209+
[
210+
"photo.webp",
211+
new Uint8Array([
212+
82, 73, 70, 70, 22, 0, 0, 0, 87, 69, 66, 80, 86, 80, 56, 88, 10, 0, 0, 0, 0, 0, 0, 0, 15,
213+
39, 0, 15, 39, 0,
214+
]),
215+
],
216+
[
217+
"photo.gif",
218+
new Uint8Array([
219+
71, 73, 70, 56, 57, 97, 1, 0, 1, 0, 0, 0, 0, 44, 0, 0, 0, 0, 16, 39, 16, 39, 0,
220+
]),
221+
],
222+
])("rejects oversized decoded images before creating a Blob: %s", async (name, bytes) => {
223+
const preview = await loadZipPreview(await archive(new JSZip().file(name, bytes)), signal());
224+
const blob = vi.spyOn(globalThis, "Blob");
225+
try {
226+
await expect(preview.readFile(preview.entries[0]!, signal())).rejects.toThrow(
227+
/16 million pixels.*8,192.*Download the ZIP/i,
228+
);
229+
expect(blob).not.toHaveBeenCalled();
230+
} finally {
231+
blob.mockRestore();
232+
}
233+
});
234+
235+
it.each([
236+
["photo.png", png(4000, 4000)],
237+
["photo.png", png(8192, 1)],
238+
[
239+
"photo.gif",
240+
new Uint8Array([
241+
71, 73, 70, 56, 57, 97, 32, 3, 88, 2, 0, 0, 0, 44, 0, 0, 0, 0, 32, 3, 88, 2, 0,
242+
]),
243+
],
244+
[
245+
"photo.gif",
246+
Uint8Array.from(atob("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"), (c) =>
247+
c.charCodeAt(0),
248+
),
249+
],
250+
["photo.jpg", new Uint8Array([255, 216, 255, 192, 0, 17, 8, 2, 88, 3, 32])],
251+
[
252+
"photo.webp",
253+
new Uint8Array([
254+
82, 73, 70, 70, 22, 0, 0, 0, 87, 69, 66, 80, 86, 80, 56, 32, 10, 0, 0, 0, 0, 0, 0, 157, 1,
255+
42, 32, 3, 88, 2,
256+
]),
257+
],
258+
[
259+
"photo.webp",
260+
new Uint8Array([
261+
82, 73, 70, 70, 17, 0, 0, 0, 87, 69, 66, 80, 86, 80, 56, 76, 5, 0, 0, 0, 47, 31, 195, 149,
262+
0,
263+
]),
264+
],
265+
[
266+
"photo.webp",
267+
new Uint8Array([
268+
82, 73, 70, 70, 22, 0, 0, 0, 87, 69, 66, 80, 86, 80, 56, 88, 10, 0, 0, 0, 0, 0, 0, 0, 31, 3,
269+
0, 87, 2, 0,
270+
]),
271+
],
272+
])("keeps bounded %s images available to the shared preview", async (name, bytes) => {
273+
const preview = await loadZipPreview(await archive(new JSZip().file(name, bytes)), signal());
274+
const image = await preview.readFile(preview.entries[0]!, signal());
275+
expect(new Uint8Array(await image.arrayBuffer())).toEqual(bytes);
276+
expect(image.type).toMatch(/^image\//);
277+
});
278+
279+
it.each([
280+
["photo.svg", '<svg xmlns="http://www.w3.org/2000/svg" width="10000" height="10000"/>'],
281+
["photo.avif", "unsupported header"],
282+
["photo.bmp", "BM"],
283+
["photo.ico", "unsupported header"],
284+
["photo.jpg", new Uint8Array([255, 216, 255])],
285+
["photo.png", png(0, 600)],
286+
["photo.gif", png().subarray(0, 20)],
287+
["photo.gif", new Uint8Array([71, 73, 70, 56, 57, 97, 1, 0, 1, 0, 0, 0, 0, 33, 249, 255])],
288+
["photo.png", '<svg xmlns="http://www.w3.org/2000/svg"/>'],
289+
])("blocks unreadable dimensions without decoding: %s", async (name, bytes) => {
290+
const preview = await loadZipPreview(await archive(new JSZip().file(name, bytes)), signal());
291+
await expect(preview.readFile(preview.entries[0]!, signal())).rejects.toThrow(
292+
/Cannot safely preview this image.*Download the ZIP/i,
293+
);
294+
});
295+
296+
it("reads a JPEG frame across extracted chunks but rejects headers beyond the scan limit", async () => {
297+
for (const segments of [1, 5]) {
298+
const bytes = new Uint8Array(2 + segments * 65537 + 9);
299+
bytes.set([255, 216]);
300+
for (let index = 0; index < segments; index++) {
301+
bytes.set([255, 224, 255, 255], 2 + index * 65537);
302+
}
303+
bytes.set([255, 192, 0, 17, 8, 2, 88, 3, 32], bytes.length - 9);
304+
const preview = await loadZipPreview(
305+
await archive(new JSZip().file("photo.jpg", bytes)),
306+
signal(),
307+
);
308+
const reading = preview.readFile(preview.entries[0]!, signal());
309+
if (segments === 1) expect((await reading).size).toBe(bytes.length);
310+
else await expect(reading).rejects.toThrow(/Cannot safely preview/i);
311+
}
193312
});
194313

195314
it.each(["photos\\image.png", "photos\\\\image.png", "photos//image.png"])(
196315
"keeps folder and file paths consistent for %s",
197316
async (path) => {
198-
const zip = new JSZip().file(path, new Uint8Array([1, 2, 3]), { createFolders: false });
317+
const zip = new JSZip().file(path, png(), { createFolders: false });
199318
zip.file(path.slice(0, -"image.png".length), null, { dir: true, createFolders: false });
200319
const preview = await loadZipPreview(await archive(zip), signal());
201320

@@ -205,12 +324,12 @@ describe("loadZipPreview", () => {
205324
path: "photos/image.png",
206325
name: "image.png",
207326
isDirectory: false,
208-
sizeBytes: 3,
327+
sizeBytes: 24,
209328
mimeType: "image/png",
210329
},
211330
]);
212331
const image = await preview.readFile(preview.entries[1]!, signal());
213-
expect(new Uint8Array(await image.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3]));
332+
expect(new Uint8Array(await image.arrayBuffer())).toEqual(png());
214333
},
215334
);
216335

@@ -237,7 +356,6 @@ describe("loadZipPreview", () => {
237356
["document.pdf", "%PDF-1.4", "application/pdf"],
238357
["clip.mp4", "media", "video/mp4"],
239358
["sound.mp3", "media", "audio/mpeg"],
240-
["image.svg", "<svg/>", "image/svg+xml"],
241359
])(
242360
"extracts only the selected file %s for the shared viewer",
243361
async (name, contents, mimeType) => {
@@ -394,7 +512,9 @@ describe("loadZipPreview", () => {
394512
});
395513

396514
it("cancels image reads after the stream starts", async () => {
397-
const zip = new JSZip().file("photo.png", new Uint8Array(64 * 1024));
515+
const contents = new Uint8Array(64 * 1024);
516+
contents.set(png());
517+
const zip = new JSZip().file("photo.png", contents);
398518
const preview = await loadZipPreview(await archive(zip), signal());
399519
const controller = new AbortController();
400520
const prototype = Object.getPrototypeOf(zip.file("photo.png")) as {

‎apps/web/src/lib/zipPreview.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import { hostPreviewMimeTypeFromExtension } from "@t3tools/shared/filePreview";
2+
import {
3+
IMAGE_DIMENSIONS_HEADER_BYTES,
4+
readImageDimensions,
5+
} from "@t3tools/shared/imageDimensions";
26
import JSZip from "jszip";
37

48
const MAX_ARCHIVE_BYTES = 32 * 1024 * 1024;
59
const MAX_FILES = 2000;
610
const MAX_FOLDERS = 2000;
711
const MAX_FILE_BYTES = 10 * 1024 * 1024;
12+
const MAX_IMAGE_PIXELS = 16_000_000;
13+
const MAX_IMAGE_EDGE = 8192;
814
const ARCHIVE_LIMIT_ERROR = "ZIP preview supports archives up to 32 MiB.";
915
const ENTRY_LIMIT_ERROR = "ZIP preview supports up to 2,000 files and 2,000 folders.";
1016
const INVALID_ARCHIVE_ERROR =
@@ -107,6 +113,32 @@ function checkArchiveDirectory(bytes: ArrayBuffer) {
107113
if (offset !== end || actualCount !== count) throw new Error(INVALID_ARCHIVE_ERROR);
108114
}
109115

116+
function zipImageDimensions(bytes: Uint8Array) {
117+
const dimensions = readImageDimensions(bytes);
118+
if (!dimensions || bytes[0] !== 0x47) return dimensions;
119+
if (bytes.length < 13) return null;
120+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
121+
const packed = bytes[10]!;
122+
let offset = 13 + (packed & 0x80 ? 3 * (2 << (packed & 7)) : 0);
123+
while (bytes[offset] === 0x21) {
124+
offset += 2;
125+
while (offset < bytes.length && bytes[offset] !== 0) offset += 1 + bytes[offset]!;
126+
if (offset >= bytes.length) return null;
127+
offset++;
128+
}
129+
if (bytes[offset] !== 0x2c || offset + 10 > bytes.length) return null;
130+
return {
131+
width: Math.max(
132+
dimensions.width,
133+
view.getUint16(offset + 1, true) + view.getUint16(offset + 5, true),
134+
),
135+
height: Math.max(
136+
dimensions.height,
137+
view.getUint16(offset + 3, true) + view.getUint16(offset + 7, true),
138+
),
139+
};
140+
}
141+
110142
function readFile(file: ZipFile, mimeType: string, signal: AbortSignal): Promise<Blob> {
111143
signal.throwIfAborted();
112144
const stream = file.internalStream("uint8array");
@@ -138,6 +170,33 @@ function readFile(file: ZipFile, mimeType: string, signal: AbortSignal): Promise
138170
);
139171
stream.on("end", () => {
140172
if (settled) return;
173+
if (mimeType.startsWith("image/")) {
174+
const header = new Uint8Array(Math.min(size, IMAGE_DIMENSIONS_HEADER_BYTES));
175+
let offset = 0;
176+
for (const chunk of chunks) {
177+
const part = chunk.subarray(0, header.length - offset);
178+
header.set(part, offset);
179+
offset += part.length;
180+
if (offset === header.length) break;
181+
}
182+
const dimensions = zipImageDimensions(header);
183+
if (!dimensions) {
184+
fail(new Error("Cannot safely preview this image. Download the ZIP to open it."));
185+
return;
186+
}
187+
if (
188+
dimensions.width > MAX_IMAGE_EDGE ||
189+
dimensions.height > MAX_IMAGE_EDGE ||
190+
dimensions.width * dimensions.height > MAX_IMAGE_PIXELS
191+
) {
192+
fail(
193+
new Error(
194+
"Image preview supports up to 16 million pixels and 8,192 pixels per side. Download the ZIP to open it.",
195+
),
196+
);
197+
return;
198+
}
199+
}
141200
settled = true;
142201
signal.removeEventListener("abort", abort);
143202
resolve(new Blob(chunks, { type: mimeType }));

0 commit comments

Comments
 (0)