diff --git a/apps/worker/src/downloads.ts b/apps/worker/src/downloads.ts index 134838d30..f290a072c 100644 --- a/apps/worker/src/downloads.ts +++ b/apps/worker/src/downloads.ts @@ -122,8 +122,8 @@ export async function capturePdfDownload(options: { new Transform({ transform(chunk: Buffer, _encoding, done) { bytes += chunk.length; - if (prefix.length < 5) - prefix = Buffer.concat([prefix, chunk.subarray(0, 5 - prefix.length)]); + if (prefix.length < 1024) + prefix = Buffer.concat([prefix, chunk.subarray(0, 1024 - prefix.length)]); if (bytes > MAX_DOWNLOAD_BYTES) { oversized = true; done(new Error("Download too large")); @@ -132,7 +132,8 @@ export async function capturePdfDownload(options: { }), createWriteStream(destination, { mode: 0o600, flags: "wx" }), ); - if (prefix.toString("ascii") !== "%PDF-") { + // Match the importer's bounded prefix check, without lossy ASCII decoding. + if (prefix.indexOf("%PDF-") < 0) { outcome.code = "UNSUPPORTED_DOWNLOAD"; outcome.message = "Only PDF downloads can be imported. This file was not a PDF."; throw new Error("Unsupported download"); diff --git a/tests/browser-pdf-prefix.test.ts b/tests/browser-pdf-prefix.test.ts new file mode 100644 index 000000000..7c96f4a22 --- /dev/null +++ b/tests/browser-pdf-prefix.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable } from "node:stream"; +import test from "node:test"; +import { capturePdfDownload, readDownloadFailures } from "../apps/worker/src/downloads.ts"; +import { createSamplePdf, inspectPdf } from "../packages/integrations/src/pdf.ts"; + +test("browser PDF downloads accept the same bounded header prefix as PDF imports", async (t) => { + const pdf = await createSamplePdf(); + for (const prefix of [ + Buffer.alloc(0), + Buffer.from([0xef, 0xbb, 0xbf, 0x0a]), + Buffer.alloc(1019, 32), + ]) { + const directory = await mkdtemp(join(tmpdir(), "openmuse-pdf-prefix-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const bytes = Buffer.concat([prefix, pdf]); + assert.equal((await inspectPdf(bytes)).pageCount, 2); + await capturePdfDownload({ + directory, + tempDirectory: directory, + limitReached: false, + download: { + suggestedFilename: () => "form.pdf", + createReadStream: async () => + Readable.from([ + bytes.subarray(0, prefix.length + 2), + bytes.subarray(prefix.length + 2, prefix.length + 4), + bytes.subarray(prefix.length + 4), + ]), + cancel: async () => {}, + delete: async () => {}, + }, + }); + assert.deepEqual(await readDownloadFailures(directory), []); + const metadataFiles = (await readdir(join(directory, "downloads"))).filter((name) => + name.endsWith(".json"), + ); + assert.equal(metadataFiles.length, 1); + const metadata = JSON.parse( + await readFile(join(directory, "downloads", metadataFiles[0]), "utf8"), + ); + assert.equal(metadata.name, "form.pdf"); + assert.equal(metadata.size, bytes.length); + assert.deepEqual(await readFile(join(directory, "downloads", `${metadata.id}.pdf`)), bytes); + } +}); + +test("browser PDF sniffing rejects late markers and non-ASCII lookalikes", async (t) => { + for (const bytes of [ + Buffer.concat([Buffer.alloc(1020, 32), Buffer.from("%PDF-1.7")]), + Buffer.from([0xa5, 0xd0, 0xc4, 0xc6, 0xad]), + Buffer.from("not a PDF"), + ]) { + const directory = await mkdtemp(join(tmpdir(), "openmuse-pdf-invalid-prefix-")); + t.after(() => rm(directory, { recursive: true, force: true })); + await capturePdfDownload({ + directory, + tempDirectory: directory, + limitReached: false, + download: { + suggestedFilename: () => "invalid.pdf", + createReadStream: async () => Readable.from([bytes]), + cancel: async () => {}, + delete: async () => {}, + }, + }); + assert.equal((await readDownloadFailures(directory))[0]?.code, "UNSUPPORTED_DOWNLOAD"); + assert.deepEqual(await readdir(join(directory, "downloads")), []); + } +});