Skip to content

Commit 6a006c4

Browse files
committed
fix(file-parsers): guard .doc uploads against zip-bomb memory exhaustion
DocParser handed the raw upload straight to officeparser and then mammoth, both of which inflate every ZIP entry into memory before any app-level size cap applies. The extension is only a routing hint, so a bomb-bearing OOXML archive renamed to .doc selected the one parser that skipped the guard its docx/pptx/xlsx siblings all call. Adds assertOoxmlArchiveWithinLimits to DocParser.parseBuffer, and centrally in file-parsers parseBuffer so a future parser cannot silently opt out. The guard reads the central directory's declared sizes without decompressing, and no-ops for non-ZIP buffers, so legacy OLE .doc files are unaffected.
1 parent 03649e9 commit 6a006c4

3 files changed

Lines changed: 118 additions & 0 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import JSZip from 'jszip'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockParseOfficeAsync, mockExtractRawText } = vi.hoisted(() => ({
8+
mockParseOfficeAsync: vi.fn(),
9+
mockExtractRawText: vi.fn(),
10+
}))
11+
12+
vi.mock('officeparser', () => ({ parseOfficeAsync: mockParseOfficeAsync }))
13+
vi.mock('mammoth', () => ({
14+
default: { extractRawText: mockExtractRawText },
15+
extractRawText: mockExtractRawText,
16+
}))
17+
18+
import { DocParser } from '@/lib/file-parsers/doc-parser'
19+
20+
const CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50
21+
22+
/**
23+
* Build a small OOXML-shaped archive whose central directory *declares* a huge
24+
* uncompressed size. The guard reads declared sizes without inflating anything,
25+
* so this reproduces a zip bomb's central directory at a few hundred bytes.
26+
*/
27+
async function buildDeclaredOversizeArchive(declaredUncompressedBytes: number): Promise<Buffer> {
28+
const zip = new JSZip()
29+
zip.file('word/document.xml', '<w:document><w:body>A</w:body></w:document>')
30+
const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
31+
32+
for (let offset = 0; offset + 28 <= buffer.length; offset++) {
33+
if (buffer.readUInt32LE(offset) === CENTRAL_DIRECTORY_HEADER_SIGNATURE) {
34+
buffer.writeUInt32LE(declaredUncompressedBytes, offset + 24)
35+
return buffer
36+
}
37+
}
38+
throw new Error('No central directory header found in generated archive')
39+
}
40+
41+
/** A legacy OLE compound-file `.doc` — not a ZIP, so the guard must no-op. */
42+
function buildLegacyOleDoc(): Buffer {
43+
const buffer = Buffer.alloc(512)
44+
Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]).copy(buffer, 0)
45+
return buffer
46+
}
47+
48+
describe('DocParser.parseBuffer', () => {
49+
beforeEach(() => {
50+
vi.clearAllMocks()
51+
})
52+
53+
it('rejects a ZIP-shaped .doc whose declared expanded size exceeds the cap', async () => {
54+
const bomb = await buildDeclaredOversizeArchive(2 * 1024 * 1024 * 1024)
55+
56+
await expect(new DocParser().parseBuffer(bomb)).rejects.toThrow(/exceeds the maximum allowed/)
57+
})
58+
59+
it('rejects the bomb before either decompression library sees the buffer', async () => {
60+
const bomb = await buildDeclaredOversizeArchive(2 * 1024 * 1024 * 1024)
61+
62+
await expect(new DocParser().parseBuffer(bomb)).rejects.toThrow()
63+
expect(mockParseOfficeAsync).not.toHaveBeenCalled()
64+
expect(mockExtractRawText).not.toHaveBeenCalled()
65+
})
66+
67+
it('rejects a ZIP-shaped .doc whose central directory cannot be parsed', async () => {
68+
const buffer = Buffer.alloc(64)
69+
buffer.writeUInt32LE(0x04034b50, 0)
70+
71+
await expect(new DocParser().parseBuffer(buffer)).rejects.toThrow(
72+
/refusing to parse an unverifiable ZIP-shaped archive/
73+
)
74+
expect(mockParseOfficeAsync).not.toHaveBeenCalled()
75+
})
76+
77+
it('still parses a well-formed OOXML archive renamed to .doc', async () => {
78+
const zip = new JSZip()
79+
zip.file('word/document.xml', '<w:document><w:body>hello</w:body></w:document>')
80+
const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
81+
mockParseOfficeAsync.mockResolvedValue('hello')
82+
83+
const result = await new DocParser().parseBuffer(buffer)
84+
85+
expect(result.content).toBe('hello')
86+
expect(result.metadata.extractionMethod).toBe('officeparser')
87+
})
88+
89+
it('no-ops the guard for a legacy OLE .doc and parses it', async () => {
90+
mockParseOfficeAsync.mockResolvedValue('legacy doc text')
91+
92+
const result = await new DocParser().parseBuffer(buildLegacyOleDoc())
93+
94+
expect(mockParseOfficeAsync).toHaveBeenCalledOnce()
95+
expect(result.content).toBe('legacy doc text')
96+
})
97+
98+
it('rejects an empty buffer', async () => {
99+
await expect(new DocParser().parseBuffer(Buffer.alloc(0))).rejects.toThrow('Empty buffer')
100+
})
101+
})

apps/sim/lib/file-parsers/doc-parser.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { readFile } from 'fs/promises'
33
import { createLogger } from '@sim/logger'
44
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
55
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
6+
import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard'
67

78
const logger = createLogger('DocParser')
89

@@ -25,12 +26,20 @@ export class DocParser implements FileParser {
2526
}
2627
}
2728

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

41+
assertOoxmlArchiveWithinLimits(buffer)
42+
3443
try {
3544
const officeParser = await import('officeparser')
3645
const result = await officeParser.parseOfficeAsync(buffer)

apps/sim/lib/file-parsers/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { existsSync } from 'fs'
22
import path from 'path'
33
import { createLogger } from '@sim/logger'
44
import type { FileParseResult, FileParser, SupportedFileType } from '@/lib/file-parsers/types'
5+
import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard'
56

67
const logger = createLogger('FileParser')
78

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

188+
assertOoxmlArchiveWithinLimits(buffer)
189+
182190
const normalizedExtension = extension.toLowerCase()
183191
logger.info('Attempting to parse buffer with extension:', normalizedExtension)
184192

0 commit comments

Comments
 (0)