mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
fix: harden multi-file response detection so merge can't fail silently (#6516)
Co-authored-by: Reece Browne <[email protected]>
This commit is contained in:
co-authored by
Reece Browne
parent
cb687fbf99
commit
bd9ef0586b
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { zipFileService } from "@app/services/zipFileService";
|
||||
|
||||
// jsdom's Blob.slice(...).arrayBuffer() returns an empty buffer in this
|
||||
// version, so a real Blob would never reach the magic-byte branch under test.
|
||||
// Duck-type the parts isZipResponse actually touches.
|
||||
function fakeBlob(bytes: number[], type = ""): Blob {
|
||||
const u8 = new Uint8Array(bytes);
|
||||
const slice = (start: number, end?: number) =>
|
||||
fakeBlob(Array.from(u8.slice(start, end)), type);
|
||||
return {
|
||||
size: u8.byteLength,
|
||||
type,
|
||||
slice,
|
||||
arrayBuffer: async () =>
|
||||
u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength),
|
||||
} as unknown as Blob;
|
||||
}
|
||||
|
||||
const PDF_BYTES = [0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37]; // "%PDF-1.7"
|
||||
|
||||
describe("zipFileService.isZipResponse", () => {
|
||||
describe("signature is authoritative", () => {
|
||||
test("PK\\x03\\x04 -> ZIP", async () => {
|
||||
expect(
|
||||
await zipFileService.isZipResponse(
|
||||
fakeBlob([0x50, 0x4b, 0x03, 0x04, 0x00, 0x00]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("PK\\x05\\x06 (empty archive) -> ZIP", async () => {
|
||||
expect(
|
||||
await zipFileService.isZipResponse(fakeBlob([0x50, 0x4b, 0x05, 0x06])),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("PK\\x07\\x08 (spanned marker) -> ZIP", async () => {
|
||||
expect(
|
||||
await zipFileService.isZipResponse(fakeBlob([0x50, 0x4b, 0x07, 0x08])),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("%PDF signature overrides a ZIP Content-Type", async () => {
|
||||
expect(
|
||||
await zipFileService.isZipResponse(
|
||||
fakeBlob(PDF_BYTES),
|
||||
"application/zip",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Bug-trigger Content-Types: previously misrouted PDFs into ZIP extraction.
|
||||
describe("bug-trigger Content-Types must not misroute a PDF", () => {
|
||||
test('"application/pdf;charset=UTF-8" + %PDF -> NOT ZIP', async () => {
|
||||
expect(
|
||||
await zipFileService.isZipResponse(
|
||||
fakeBlob(PDF_BYTES),
|
||||
"application/pdf;charset=UTF-8",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('"application/octet-stream" + %PDF -> NOT ZIP', async () => {
|
||||
expect(
|
||||
await zipFileService.isZipResponse(
|
||||
fakeBlob(PDF_BYTES),
|
||||
"application/octet-stream",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('"APPLICATION/PDF" + %PDF -> NOT ZIP', async () => {
|
||||
expect(
|
||||
await zipFileService.isZipResponse(
|
||||
fakeBlob(PDF_BYTES),
|
||||
"APPLICATION/PDF",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Content-Type fallback when signature is inconclusive", () => {
|
||||
const UNKNOWN_BYTES = [0xff, 0xff, 0xff, 0xff];
|
||||
|
||||
test('unknown bytes + "application/zip" -> ZIP', async () => {
|
||||
expect(
|
||||
await zipFileService.isZipResponse(
|
||||
fakeBlob(UNKNOWN_BYTES),
|
||||
"application/zip",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('unknown bytes + "application/x-zip-compressed" -> ZIP', async () => {
|
||||
expect(
|
||||
await zipFileService.isZipResponse(
|
||||
fakeBlob(UNKNOWN_BYTES),
|
||||
"application/x-zip-compressed",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('unknown bytes + "application/json" -> NOT ZIP', async () => {
|
||||
expect(
|
||||
await zipFileService.isZipResponse(
|
||||
fakeBlob(UNKNOWN_BYTES),
|
||||
"application/json",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("unknown bytes, no hint, blob.type 'application/zip' -> ZIP", async () => {
|
||||
expect(
|
||||
await zipFileService.isZipResponse(
|
||||
fakeBlob(UNKNOWN_BYTES, "application/zip"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -303,6 +303,37 @@ export class ZipFileService {
|
||||
return hasValidType || hasValidExtension;
|
||||
}
|
||||
|
||||
// Signature-first ZIP detection for HTTP response bodies. Content-Type alone
|
||||
// is unreliable: proxies and some backends ship merged PDFs as
|
||||
// "application/pdf;charset=UTF-8" or "application/octet-stream", which an
|
||||
// exact-equality check would misroute into ZIP extraction.
|
||||
public async isZipResponse(
|
||||
blob: Blob,
|
||||
contentTypeHint?: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const sig = new Uint8Array(await blob.slice(0, 4).arrayBuffer());
|
||||
if (
|
||||
sig[0] === 0x50 &&
|
||||
sig[1] === 0x4b &&
|
||||
(sig[2] === 0x03 || sig[2] === 0x05 || sig[2] === 0x07)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
sig[0] === 0x25 &&
|
||||
sig[1] === 0x50 &&
|
||||
sig[2] === 0x44 &&
|
||||
sig[3] === 0x46
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// Unreadable blob - fall back to Content-Type.
|
||||
}
|
||||
return (contentTypeHint || blob.type || "").toLowerCase().includes("zip");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a filename indicates a PDF file
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user