const assert = require("assert"); const fs = require("fs"); const path = require("path"); const vm = require("vm"); const { deflateRawSync } = require("zlib"); const sandbox = { self: {}, TextDecoder, Uint8Array, DataView }; sandbox.self = sandbox; vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../static/reader-security.js"), "utf8"), sandbox); const security = sandbox.VoiceOfMLReaderSecurity; function crc32(bytes) { let crc = 0xffffffff; for (const byte of bytes) { crc ^= byte; for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); } return (crc ^ 0xffffffff) >>> 0; } function zip(entries, comment = Buffer.alloc(0)) { const locals = [], directory = []; let position = 0; for (const { name, data, deflate = false, descriptor = false, extra = Buffer.alloc(0) } of entries) { const filename = Buffer.from(name), packed = deflate ? deflateRawSync(data) : data; const crc = crc32(data), method = deflate ? 8 : 0, flags = descriptor ? 8 : 0; const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50); local.writeUInt16LE(20, 4); local.writeUInt16LE(flags, 6); local.writeUInt16LE(method, 8); if (!descriptor) { local.writeUInt32LE(crc, 14); local.writeUInt32LE(packed.length, 18); local.writeUInt32LE(data.length, 22); } local.writeUInt16LE(filename.length, 26); const central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(flags, 8); central.writeUInt16LE(method, 10); central.writeUInt32LE(crc, 16); central.writeUInt32LE(packed.length, 20); central.writeUInt32LE(data.length, 24); central.writeUInt16LE(filename.length, 28); central.writeUInt16LE(extra.length, 30); central.writeUInt32LE(position, 42); const trailer = Buffer.alloc(descriptor ? 16 : 0); if (descriptor) { trailer.writeUInt32LE(0x08074b50); trailer.writeUInt32LE(crc, 4); trailer.writeUInt32LE(packed.length, 8); trailer.writeUInt32LE(data.length, 12); } locals.push(local, filename, packed, trailer); directory.push(central, filename, extra); position += local.length + filename.length + packed.length + trailer.length; } const central = Buffer.concat(directory), end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10); end.writeUInt32LE(central.length, 12); end.writeUInt32LE(position, 16); end.writeUInt16LE(comment.length, 20); return { bytes: Buffer.concat([...locals, central, end, comment]), directory: position, end: position + central.length }; } function streamResponse(chunks, header = null) { const state = { reads: 0, cancels: 0, released: false }; const response = { headers: { get: () => header }, body: { cancel: async () => { state.cancels++; }, getReader: () => ({ read: async () => { const value = chunks[state.reads++]; return value === undefined ? { done: true } : { value, done: false }; }, cancel: async () => { state.cancels++; }, releaseLock: () => { state.released = true; }, }), }, }; return { response, state }; } async function main() { const valid = zip([{ name: "chapter.xhtml", data: Buffer.from("Readable chapter"), deflate: true, descriptor: true }], Buffer.from("ZIP comment PK\x05\x06")); assert.strictEqual(security.inspectZip(valid.bytes).entries, 1); assert.strictEqual(security.inspectZip(valid.bytes).compressed, deflateRawSync(Buffer.from("Readable chapter")).length); assert.strictEqual(security.inspectZip(valid.bytes).expanded, 16); assert.strictEqual(security.isZipContainer("mobi", valid.bytes), true); assert.strictEqual(security.isZipContainer("FBZ"), true); assert.strictEqual(security.isZipContainer("mobi", new Uint8Array(4)), false); assert.strictEqual(security.isZipContainer("mobi"), false); // This stored member previously redirected the scanner past the real directory. const decoy = Buffer.alloc(46); decoy.writeUInt32LE(0x02014b50); decoy.writeUInt32LE(100, 20); decoy.writeUInt32LE(100, 24); decoy.writeUInt16LE(65535, 28); const bomb = zip([ { name: "decoy.bin", data: decoy }, { name: "chapter.xhtml", data: Buffer.alloc(1024 * 1024, 65), deflate: true }, ]); assert.throws(() => security.inspectZip(bomb.bytes), /READER_ARCHIVE_LIMIT/); decoy.writeUInt32LE(0xffffffff, 24); const harmless = zip([{ name: "decoy.bin", data: decoy }]); assert.strictEqual(security.inspectZip(harmless.bytes).expanded, decoy.length); assert.throws(() => security.inspectZip(decoy), /READER_ARCHIVE_INVALID/); assert.throws(() => security.inspectZip(valid.bytes.subarray(0, valid.end + 10)), /READER_ARCHIVE_INVALID/); assert.throws(() => security.inspectZip(new Uint8Array(3)), /READER_ARCHIVE_INVALID/); assert.throws(() => security.inspectZip(valid.bytes, { ...security.LIMITS, archiveCompressedBytes: 1 }), /READER_ARCHIVE_LIMIT/); assert.throws(() => security.inspectZip(valid.bytes, { ...security.LIMITS, archiveExpandedBytes: 1 }), /READER_ARCHIVE_LIMIT/); assert.throws(() => security.inspectZip(valid.bytes, { ...security.LIMITS, archiveEntries: 0 }), /READER_ARCHIVE_LIMIT/); for (const [mutate, expected] of [ [(b) => b.writeUInt32LE(valid.directory + 1, valid.end + 16), /READER_ARCHIVE_INVALID/], [(b) => { b.writeUInt16LE(2, valid.end + 8); b.writeUInt16LE(2, valid.end + 10); }, /READER_ARCHIVE_INVALID/], [(b) => b.writeUInt16LE(65535, valid.directory + 28), /READER_ARCHIVE_INVALID/], [(b) => b.writeUInt32LE(valid.directory, valid.directory + 42), /READER_ARCHIVE_INVALID/], [(b) => b.writeUInt32LE(0, 0), /READER_ARCHIVE_INVALID/], [(b) => b.writeUInt16LE(1, valid.end + 4), /READER_ARCHIVE_MULTIDISK_UNSUPPORTED/], [(b) => b.writeUInt16LE(1, valid.directory + 34), /READER_ARCHIVE_MULTIDISK_UNSUPPORTED/], [(b) => b.writeUInt16LE(0xffff, valid.end + 10), /READER_ARCHIVE_ZIP64_UNSUPPORTED/], [(b) => b.writeUInt32LE(0xffffffff, valid.directory + 24), /READER_ARCHIVE_ZIP64_UNSUPPORTED/], ]) { const bytes = Buffer.from(valid.bytes); mutate(bytes); assert.throws(() => security.inspectZip(bytes), expected); } const zip64Extra = zip([{ name: "a", data: Buffer.from("a"), extra: Buffer.from([1, 0, 0, 0]) }]); assert.throws(() => security.inspectZip(zip64Extra.bytes), /READER_ARCHIVE_ZIP64_UNSUPPORTED/); const chapterManifest = { version: 1, kind: "epub-chapters", chapters: [{ index: 1, path: "chapter.xhtml", bytes: 10 }] }; security.validateChapterManifest(chapterManifest); for (const [chapter, expected] of [ [{ index: 1, path: "chapter.xhtml", bytes: security.LIMITS.chapterBytes + 1 }, /READER_RESOURCE_LIMIT/], [{ index: 2, path: "chapter.xhtml" }, /EPUB_INVALID/], [{ index: 1, path: "../escape.xhtml" }, /EPUB_INVALID/], ]) assert.throws(() => security.validateChapterManifest({ ...chapterManifest, chapters: [chapter] }), expected); const validTocManifest = { version: 2, kind: "pdf-pages", page_count: 1, toc: [{ title: "Chapter 1", page: 1, depth: 0 }] }; security.validatePdfPageManifest(validTocManifest); security.validatePdfPageManifest({ version: 2, kind: "pdf-pages", page_count: 100, toc: [{ title: "Chapter 1", page: 100, depth: 0 }] }); assert.throws(() => security.validatePdfPageManifest({ version: 2, kind: "pdf-pages", page_count: 0 }), /PDF_MANIFEST_INVALID/); assert.throws(() => security.validatePdfPageManifest({ version: 2, kind: "pdf-pages", page_count: 1, pages: [] }), /PDF_MANIFEST_INVALID/); assert.throws(() => security.validatePdfPageManifest({ ...validTocManifest, toc: [{ title: "bad", page: 2, depth: 0 }] }), /PDF_MANIFEST_INVALID/); assert.throws(() => security.validatePdfPageManifest({ ...validTocManifest, pages: [] }), /PDF_MANIFEST_INVALID/); assert.throws(() => security.validatePdfPageManifest({ version: 1, kind: "pdf-pages", pages: [{ page: 1, path: "pages/page-000001.webp" }] }), /PDF_MANIFEST_INVALID/); const ocrRoot = "objects/aa/" + "a".repeat(64) + "/" + "b".repeat(16); security.validatePdfOcrManifest({ version: 1, kind: "pdf-ocr", complete: true, source_sha256: "a".repeat(64), profile: "test-layout-layout-v2-1234567890abcdef", page_count: 1, pages: [{ p: 1, o: ocrRoot + "/ocr/page-000001.json.gz" }], book_text: { path: ocrRoot + "/ocr/book-text.json.gz", sha256: "c".repeat(64), bytes: 1 } }); const oversized = streamResponse([], "9"); assert.throws(() => security.assertResponseSize(oversized.response, 8), /READER_RESOURCE_LIMIT/); await assert.rejects(security.readBytes(oversized.response, 8), /READER_RESOURCE_LIMIT/); assert.strictEqual(oversized.state.reads, 0); assert.strictEqual(oversized.state.cancels, 1); oversized.response.body.cancel = async () => { throw new Error("cancel failed"); }; await assert.rejects(security.readBytes(oversized.response, 8), /READER_RESOURCE_LIMIT/); const overflow = streamResponse([new Uint8Array(5), new Uint8Array(5), new Uint8Array(5)]); await assert.rejects(security.readBytes(overflow.response, 8), /READER_RESOURCE_LIMIT/); assert.deepStrictEqual(overflow.state, { reads: 2, cancels: 1, released: true }); assert.strictEqual(await security.readText(streamResponse([Buffer.from("text")]).response, 4), "text"); const budget = security.createByteBudget(8); const first = streamResponse([new Uint8Array(3), new Uint8Array(3)]); const second = streamResponse([new Uint8Array(3), new Uint8Array(3)]); const results = await Promise.allSettled([security.readBytes(first.response, 8, budget), security.readBytes(second.response, 8, budget)]); assert.strictEqual(results.filter((result) => result.status === "fulfilled").length, 1); assert.strictEqual(results.find((result) => result.status === "rejected").reason.code, "READER_RESOURCE_LIMIT"); assert.strictEqual(budget.used, 6); assert.strictEqual(budget.remaining, 2); assert.strictEqual(first.state.cancels + second.state.cancels, 1); budget.release(6); assert.strictEqual(await security.readText(streamResponse([Buffer.from("retry")]).response, 8, "utf-8", budget), "retry"); assert.strictEqual(budget.used, 5); const failed = streamResponse([new Uint8Array(2), new Uint8Array(9)]); await assert.rejects(security.readBytes(failed.response, 8, budget), /READER_RESOURCE_LIMIT/); assert.strictEqual(budget.used, 5); await assert.rejects(security.readBytes({ headers: { get: () => null }, arrayBuffer: async () => new ArrayBuffer(4) }, 8, budget), /READER_RESOURCE_LIMIT/); assert.strictEqual(budget.used, 5); assert.throws(() => budget.release(6), /Invalid byte release/); assert.throws(() => budget.reserve(-1), /Invalid byte count/); assert.throws(() => security.createByteBudget(-1), /Invalid byte budget/); console.log("reader security contracts passed"); } main().catch((error) => { console.error(error); process.exitCode = 1; });