diff --git a/apps/sim/lib/copilot/vfs/document-style.test.ts b/apps/sim/lib/copilot/vfs/document-style.test.ts new file mode 100644 index 00000000000..372718b4703 --- /dev/null +++ b/apps/sim/lib/copilot/vfs/document-style.test.ts @@ -0,0 +1,168 @@ +/** + * @vitest-environment node + */ +import { deflateRawSync } from 'zlib' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockLoadAsync } = vi.hoisted(() => ({ mockLoadAsync: vi.fn() })) + +vi.mock('jszip', () => ({ default: { loadAsync: mockLoadAsync } })) + +import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' + +const THEME_XML = ` + + + + + + + + + + + + +` + +interface ZipEntryInput { + name: string + content: string + /** Overrides the uncompressed size written to both headers, to model a lying archive. */ + declaredUncompressedSize?: number +} + +/** + * Emit a ZIP archive byte by byte. Generating one with a library and patching it + * afterwards cannot express a declared size that never matched the payload, and + * that divergence is the whole subject of these tests. + */ +function buildZip(entries: ZipEntryInput[]): Buffer { + const locals: Buffer[] = [] + const centrals: Buffer[] = [] + let offset = 0 + + for (const entry of entries) { + const name = Buffer.from(entry.name, 'utf8') + const raw = Buffer.from(entry.content, 'utf8') + const deflated = deflateRawSync(raw) + const declared = entry.declaredUncompressedSize ?? raw.length + + const local = Buffer.alloc(30 + name.length) + local.writeUInt32LE(0x04034b50, 0) + local.writeUInt16LE(20, 4) + local.writeUInt16LE(8, 8) // deflate + local.writeUInt32LE(0, 14) // crc32 — nothing under test validates it + local.writeUInt32LE(deflated.length, 18) + local.writeUInt32LE(declared, 22) + local.writeUInt16LE(name.length, 26) + name.copy(local, 30) + locals.push(local, deflated) + + const central = Buffer.alloc(46 + name.length) + central.writeUInt32LE(0x02014b50, 0) + central.writeUInt16LE(20, 4) + central.writeUInt16LE(20, 6) + central.writeUInt16LE(8, 10) // deflate + central.writeUInt32LE(0, 16) + central.writeUInt32LE(deflated.length, 20) + central.writeUInt32LE(declared, 24) + central.writeUInt16LE(name.length, 28) + central.writeUInt32LE(offset, 42) + name.copy(central, 46) + centrals.push(central) + + offset += local.length + deflated.length + } + + const centralDirectory = Buffer.concat(centrals) + const eocd = Buffer.alloc(22) + eocd.writeUInt32LE(0x06054b50, 0) + eocd.writeUInt16LE(entries.length, 8) + eocd.writeUInt16LE(entries.length, 10) + eocd.writeUInt32LE(centralDirectory.length, 12) + eocd.writeUInt32LE(offset, 16) + + return Buffer.concat([...locals, centralDirectory, eocd]) +} + +/** Stand-in for a loaded JSZip archive, serving the parts the extractor asks for. */ +function fakeArchive(files: Record) { + return { + file: (path: string) => (files[path] ? { async: async () => files[path] } : null), + } +} + +describe('extractDocumentStyle', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('extracts theme colors and fonts from a well-formed docx', async () => { + mockLoadAsync.mockResolvedValue(fakeArchive({ 'word/theme/theme1.xml': THEME_XML })) + + const summary = await extractDocumentStyle( + buildZip([{ name: 'word/theme/theme1.xml', content: THEME_XML }]), + 'docx' + ) + + expect(mockLoadAsync).toHaveBeenCalledOnce() + expect(summary?.theme?.colors).toMatchObject({ + dk1: '000000', + lt1: 'FFFFFF', + accent1: '4472C4', + }) + expect(summary?.theme?.fonts).toEqual({ major: 'Calibri Light', minor: 'Calibri' }) + }) + + it('extracts theme data from a well-formed pptx', async () => { + mockLoadAsync.mockResolvedValue(fakeArchive({ 'ppt/theme/theme1.xml': THEME_XML })) + + const summary = await extractDocumentStyle( + buildZip([{ name: 'ppt/theme/theme1.xml', content: THEME_XML }]), + 'pptx' + ) + + expect(summary?.theme?.fonts.minor).toBe('Calibri') + }) + + it('never hands JSZip an archive declaring more expansion than the guard allows', async () => { + // JSZip would also fail this archive — but only after inflating the entry, + // which is the memory cost the guard exists to avoid. Asserting on the + // return value alone cannot tell the two apart, so assert JSZip is never + // reached. + const bomb = buildZip([ + { + name: 'word/theme/theme1.xml', + content: THEME_XML, + declaredUncompressedSize: 2 * 1024 * 1024 * 1024, + }, + ]) + + expect(await extractDocumentStyle(bomb, 'docx')).toBeNull() + expect(mockLoadAsync).not.toHaveBeenCalled() + }) + + it('never hands JSZip an archive with an implausible compression ratio', async () => { + // 400 MiB sits under the 1 GiB absolute cap, so this exercises the ratio + // check rather than the size check. Declaring the expansion rather than + // carrying it keeps the fixture a few hundred bytes — the guard reads the + // declared total, so a real payload would only cost the suite memory. + const bomb = buildZip([ + { + name: 'word/theme/theme1.xml', + content: THEME_XML, + declaredUncompressedSize: 400 * 1024 * 1024, + }, + ]) + + expect(await extractDocumentStyle(bomb, 'docx')).toBeNull() + expect(mockLoadAsync).not.toHaveBeenCalled() + }) + + it('returns null for a buffer that is not a ZIP archive', async () => { + expect(await extractDocumentStyle(Buffer.from('not an archive at all'), 'docx')).toBeNull() + expect(await extractDocumentStyle(Buffer.alloc(2), 'docx')).toBeNull() + expect(mockLoadAsync).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/vfs/document-style.ts b/apps/sim/lib/copilot/vfs/document-style.ts index 7edbe202fc2..29d2b120a83 100644 --- a/apps/sim/lib/copilot/vfs/document-style.ts +++ b/apps/sim/lib/copilot/vfs/document-style.ts @@ -1,11 +1,9 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { assertOoxmlArchiveWithinLimits, isZipShaped } from '@/lib/file-parsers/zip-guard' const logger = createLogger('DocumentStyle') -// ZIP magic bytes: PK\x03\x04 -const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04] - interface ThemeColors { dk1: string lt1: string @@ -389,12 +387,15 @@ export async function extractDocumentStyle( return extractPdfStyle(buffer) } - if (buffer.length < 4) return null - for (let i = 0; i < 4; i++) { - if (buffer[i] !== ZIP_MAGIC[i]) return null - } + if (!isZipShaped(buffer)) return null try { + // Reading a handful of named parts still means handing an attacker-controlled + // archive to JSZip, which inflates whatever those entries hold. Bound it with + // the same guard the document parsers use rather than trusting the entry + // sizes JSZip reports, which come from the archive itself. + assertOoxmlArchiveWithinLimits(buffer) + const JSZip = (await import('jszip')).default const zip = await JSZip.loadAsync(buffer)