Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions apps/sim/lib/file-parsers/doc-parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* @vitest-environment node
*/
import JSZip from 'jszip'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockParseOfficeAsync, mockExtractRawText } = vi.hoisted(() => ({
mockParseOfficeAsync: vi.fn(),
mockExtractRawText: vi.fn(),
}))

vi.mock('officeparser', () => ({ parseOfficeAsync: mockParseOfficeAsync }))
vi.mock('mammoth', () => ({
default: { extractRawText: mockExtractRawText },
extractRawText: mockExtractRawText,
}))

import { DocParser } from '@/lib/file-parsers/doc-parser'

const CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50

/**
* Build a small OOXML-shaped archive whose central directory *declares* a huge
* uncompressed size. The guard reads declared sizes without inflating anything,
* so this reproduces a zip bomb's central directory at a few hundred bytes.
*/
async function buildDeclaredOversizeArchive(declaredUncompressedBytes: number): Promise<Buffer> {
const zip = new JSZip()
zip.file('word/document.xml', '<w:document><w:body>A</w:body></w:document>')
const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })

for (let offset = 0; offset + 28 <= buffer.length; offset++) {
if (buffer.readUInt32LE(offset) === CENTRAL_DIRECTORY_HEADER_SIGNATURE) {
buffer.writeUInt32LE(declaredUncompressedBytes, offset + 24)
return buffer
}
}
throw new Error('No central directory header found in generated archive')
}

/** A legacy OLE compound-file `.doc` — not a ZIP, so the guard must no-op. */
function buildLegacyOleDoc(): Buffer {
const buffer = Buffer.alloc(512)
Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]).copy(buffer, 0)
return buffer
}

describe('DocParser.parseBuffer', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('rejects a ZIP-shaped .doc whose declared expanded size exceeds the cap', async () => {
const bomb = await buildDeclaredOversizeArchive(2 * 1024 * 1024 * 1024)

await expect(new DocParser().parseBuffer(bomb)).rejects.toThrow(/exceeds the maximum allowed/)
})

it('rejects the bomb before either decompression library sees the buffer', async () => {
const bomb = await buildDeclaredOversizeArchive(2 * 1024 * 1024 * 1024)

await expect(new DocParser().parseBuffer(bomb)).rejects.toThrow()
expect(mockParseOfficeAsync).not.toHaveBeenCalled()
expect(mockExtractRawText).not.toHaveBeenCalled()
})

it('rejects a .doc that under-declares its uncompressed size', async () => {
// Declared sizes alone put this under every limit; officeparser and mammoth
// only notice the mismatch after inflating the entry in full, so the guard
// has to catch it before either library sees the buffer.
const zip = new JSZip()
zip.file('word/document.xml', 'A'.repeat(4 * 1024 * 1024))
const honest = (await zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
})) as Buffer

const lying = Buffer.from(honest)
for (let offset = 0; offset + 30 <= lying.length; offset++) {
const signature = lying.readUInt32LE(offset)
if (signature === CENTRAL_DIRECTORY_HEADER_SIGNATURE) {
lying.writeUInt32LE(1000, offset + 24)
} else if (signature === 0x04034b50) {
lying.writeUInt32LE(1000, offset + 22)
}
}

await expect(new DocParser().parseBuffer(lying)).rejects.toThrow(/do not match declared sizes/)
expect(mockParseOfficeAsync).not.toHaveBeenCalled()
expect(mockExtractRawText).not.toHaveBeenCalled()
})

it('rejects a ZIP-shaped .doc whose central directory cannot be parsed', async () => {
const buffer = Buffer.alloc(64)
buffer.writeUInt32LE(0x04034b50, 0)

await expect(new DocParser().parseBuffer(buffer)).rejects.toThrow(
/refusing to parse an unverifiable ZIP-shaped archive/
)
expect(mockParseOfficeAsync).not.toHaveBeenCalled()
})

it('still parses a well-formed OOXML archive renamed to .doc', async () => {
const zip = new JSZip()
zip.file('word/document.xml', '<w:document><w:body>hello</w:body></w:document>')
const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
mockParseOfficeAsync.mockResolvedValue('hello')

const result = await new DocParser().parseBuffer(buffer)

expect(result.content).toBe('hello')
expect(result.metadata.extractionMethod).toBe('officeparser')
})

it('no-ops the guard for a legacy OLE .doc and parses it', async () => {
mockParseOfficeAsync.mockResolvedValue('legacy doc text')

const result = await new DocParser().parseBuffer(buildLegacyOleDoc())

expect(mockParseOfficeAsync).toHaveBeenCalledOnce()
expect(result.content).toBe('legacy doc text')
})

it('rejects an empty buffer', async () => {
await expect(new DocParser().parseBuffer(Buffer.alloc(0))).rejects.toThrow('Empty buffer')
})
})
9 changes: 9 additions & 0 deletions apps/sim/lib/file-parsers/doc-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { readFile } from 'fs/promises'
import { createLogger } from '@sim/logger'
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard'

const logger = createLogger('DocParser')

Expand All @@ -25,12 +26,20 @@ export class DocParser implements FileParser {
}
}

/**
* A `.doc` upload is only routed here by extension — `officeparser` and
* `mammoth` both accept an OOXML/ZIP container regardless of its name, so the
* zip-bomb guard must run here exactly as it does in the docx/pptx/xlsx
* parsers. It no-ops for genuine legacy OLE `.doc` buffers.
*/
async parseBuffer(buffer: Buffer): Promise<FileParseResult> {
try {
if (!buffer || buffer.length === 0) {
throw new Error('Empty buffer provided')
}

assertOoxmlArchiveWithinLimits(buffer)

try {
const officeParser = await import('officeparser')
const result = await officeParser.parseOfficeAsync(buffer)
Expand Down
8 changes: 8 additions & 0 deletions apps/sim/lib/file-parsers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync } from 'fs'
import path from 'path'
import { createLogger } from '@sim/logger'
import type { FileParseResult, FileParser, SupportedFileType } from '@/lib/file-parsers/types'
import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard'

const logger = createLogger('FileParser')

Expand Down Expand Up @@ -168,6 +169,11 @@ export async function parseFile(filePath: string): Promise<FileParseResult> {
* @param buffer Buffer containing the file data
* @param extension File extension without the dot (e.g., 'pdf', 'csv')
* @returns Parsed content and metadata
*
* The zip-bomb guard runs here for every extension, not just the OOXML ones:
* the extension is an attacker-controlled routing hint, and the guard no-ops
* for buffers that are not ZIP archives. Individual parsers still call it so a
* direct `parser.parseBuffer` caller is covered too.
*/
export async function parseBuffer(buffer: Buffer, extension: string): Promise<FileParseResult> {
try {
Expand All @@ -179,6 +185,8 @@ export async function parseBuffer(buffer: Buffer, extension: string): Promise<Fi
throw new Error('No file extension provided')
}

assertOoxmlArchiveWithinLimits(buffer)

const normalizedExtension = extension.toLowerCase()
logger.info('Attempting to parse buffer with extension:', normalizedExtension)

Expand Down
156 changes: 156 additions & 0 deletions apps/sim/lib/file-parsers/zip-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,63 @@ async function buildZip(
})
}

const CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50

/**
* Rewrite every declared uncompressed size — in both the central directory and
* the local file headers — so the archive under-reports how much it expands to.
* This is the bypass a declared-size-only check cannot see. Zero-length records
* (JSZip emits a stored directory entry per folder) are left alone so the
* archive stays well-formed apart from the lie under test.
*/
function underDeclareSizes(source: Buffer, declared: number): Buffer {
const buffer = Buffer.from(source)
for (let offset = 0; offset + 30 <= buffer.length; offset++) {
const signature = buffer.readUInt32LE(offset)
if (signature === CENTRAL_DIRECTORY_HEADER_SIGNATURE) {
if (buffer.readUInt32LE(offset + 24) !== 0) {
buffer.writeUInt32LE(declared, offset + 24)
}
} else if (signature === LOCAL_FILE_HEADER_SIGNATURE) {
if (buffer.readUInt32LE(offset + 22) !== 0) {
buffer.writeUInt32LE(declared, offset + 22)
}
}
}
return buffer
}

/**
* Overwrite the compression method on every non-empty record. `where` selects
* which header is rewritten, so a test can make the two disagree — JSZip trusts
* the central method while SheetJS switches on the local one.
*/
function setCompressionMethod(
source: Buffer,
method: number,
where: 'central' | 'local' | 'both' = 'both'
): Buffer {
const buffer = Buffer.from(source)
for (let offset = 0; offset + 46 <= buffer.length; offset++) {
const signature = buffer.readUInt32LE(offset)
if (
signature === CENTRAL_DIRECTORY_HEADER_SIGNATURE &&
buffer.readUInt32LE(offset + 24) !== 0 &&
where !== 'local'
) {
buffer.writeUInt16LE(method, offset + 10)
} else if (
signature === LOCAL_FILE_HEADER_SIGNATURE &&
buffer.readUInt32LE(offset + 22) !== 0 &&
where !== 'central'
) {
buffer.writeUInt16LE(method, offset + 8)
}
}
return buffer
}

describe('assertOoxmlArchiveWithinLimits', () => {
it('accepts a well-formed archive within limits', async () => {
const buffer = await buildZip({ 'word/document.xml': '<xml>hello world</xml>' })
Expand Down Expand Up @@ -108,6 +165,105 @@ describe('assertOoxmlArchiveWithinLimits', () => {
expect(() => assertOoxmlArchiveWithinLimits(tampered)).toThrow(ZipBombError)
})

it('rejects an archive that under-declares its uncompressed size', async () => {
// The declared sizes put this archive far under both limits, so only
// inflating it reveals that it actually expands ~200x further.
const honest = await buildZip({ 'word/document.xml': 'A'.repeat(200_000) })
const lying = underDeclareSizes(honest, 1000)

expect(() => assertOoxmlArchiveWithinLimits(lying, HIGH_LIMITS)).toThrow(ZipBombError)
expect(() => assertOoxmlArchiveWithinLimits(lying, HIGH_LIMITS)).toThrow(
/inflates beyond the 1000 bytes it declares/
)
})

it('still accepts the same archive when its declared sizes are honest', async () => {
const honest = await buildZip({ 'word/document.xml': 'A'.repeat(200_000) })
expect(() => assertOoxmlArchiveWithinLimits(honest, HIGH_LIMITS)).not.toThrow()
})

it('rejects a stored entry whose declared size does not match its payload', async () => {
const zip = new JSZip()
zip.file('document.xml', 'A'.repeat(50_000))
const stored = (await zip.generateAsync({
type: 'nodebuffer',
compression: 'STORE',
})) as Buffer

expect(() =>
assertOoxmlArchiveWithinLimits(underDeclareSizes(stored, 10), HIGH_LIMITS)
).toThrow(/stored entry declares 10 bytes but holds 50000/)
})

it('rejects an entry using a compression method the parsers cannot read', async () => {
const buffer = await buildZip({ 'word/document.xml': '<xml>hello</xml>' })
expect(() =>
assertOoxmlArchiveWithinLimits(setCompressionMethod(buffer, 12), HIGH_LIMITS)
).toThrow(/unsupported compression method 12/)
})

it('rejects an entry whose central and local compression methods disagree', async () => {
// Claiming STORED centrally skips the bounded inflation, while SheetJS
// switches on the local header and would inflate the payload anyway.
const honest = await buildZip({ 'xl/worksheets/sheet1.xml': 'A'.repeat(200_000) })
const split = setCompressionMethod(honest, 0, 'central')

expect(() => assertOoxmlArchiveWithinLimits(split, HIGH_LIMITS)).toThrow(ZipBombError)
expect(() => assertOoxmlArchiveWithinLimits(split, HIGH_LIMITS)).toThrow(
/compression method 0 centrally but 8 locally/
)
})

it('rejects an entry whose central and local declared sizes disagree', async () => {
const honest = await buildZip({ 'word/document.xml': 'A'.repeat(200_000) })
const buffer = Buffer.from(honest)
for (let offset = 0; offset + 30 <= buffer.length; offset++) {
if (
buffer.readUInt32LE(offset) === LOCAL_FILE_HEADER_SIGNATURE &&
buffer.readUInt32LE(offset + 22) !== 0
) {
buffer.writeUInt32LE(64, offset + 22)
}
}

expect(() => assertOoxmlArchiveWithinLimits(buffer, HIGH_LIMITS)).toThrow(
/200000 bytes centrally but .* locally/
)
})

it('charges entries hidden behind an under-reported EOCD count against the cap', async () => {
// JSZip's readCentralDir loops on the record signature and keeps every
// entry it finds — a count mismatch is explicitly not an error there — so
// entries past the declared count must still be charged against the cap.
const buffer = await buildZip({
'a.xml': 'A'.repeat(60_000),
'b.xml': 'B'.repeat(60_000),
'c.xml': 'C'.repeat(60_000),
})
const eocdOffset = buffer.length - 22
expect(buffer.readUInt32LE(eocdOffset)).toBe(0x06054b50)
buffer.writeUInt16LE(1, eocdOffset + 8) // entries on this disk
buffer.writeUInt16LE(1, eocdOffset + 10) // total entries

expect(() =>
assertOoxmlArchiveWithinLimits(buffer, {
maxTotalUncompressedBytes: 100_000,
maxCompressionRatio: 10_000,
ratioCheckFloorBytes: 1024 * 1024 * 1024,
})
).toThrow(/exceeds the maximum allowed/)
})

it('accepts a multi-entry archive whose entries all inflate to what they declare', async () => {
const buffer = await buildZip({
'[Content_Types].xml': '<?xml version="1.0"?><Types/>',
'_rels/.rels': '<?xml version="1.0"?><Relationships/>',
'word/document.xml': `<w:document>${'text '.repeat(5000)}</w:document>`,
'word/styles.xml': `<w:styles>${'style '.repeat(2000)}</w:styles>`,
})
expect(() => assertOoxmlArchiveWithinLimits(buffer, HIGH_LIMITS)).not.toThrow()
})

it('no-ops for buffers that are not ZIP archives', () => {
const plaintext = Buffer.from('this is just plain text, not a zip archive at all')
expect(() => assertOoxmlArchiveWithinLimits(plaintext)).not.toThrow()
Expand Down
Loading
Loading