fix(file-parsers): guard OOXML parsers against decompression-bomb memory exhaustion (#5239)

* fix(file-parsers): guard OOXML parsers against decompression-bomb memory exhaustion

Pre-inspect the ZIP central directory of xlsx/docx/pptx buffers and reject
archives whose declared expanded size (>1 GiB) or compression ratio (>150x)
exceeds safe bounds, before SheetJS/mammoth/officeparser inflate them. The
existing pipeline only capped the compressed input (100 MB), which does not
bound decompressed size, so a crafted zip bomb could expand to many GB and OOM
the worker.

* fix(file-parsers): fail closed on unverifiable ZIP-shaped OOXML archives

Address review: the guard previously no-opped (fell through to the
decompression library) whenever the central directory could not be parsed,
and findEocdOffset accepted the first backward EOCD signature without checking
it sat at the buffer tail. A crafted archive with a decoy EOCD or an
unsupported directory layout could bypass the size limits.

- findEocdOffset now requires the EOCD comment length to place the record
  exactly at the buffer tail, defeating decoy signatures planted in the trailing
  region.
- assertOoxmlArchiveWithinLimits now fails closed: a ZIP-shaped buffer (local
  file header / EOCD magic) whose central directory cannot be parsed is rejected
  rather than passed through. Genuine non-ZIP inputs (legacy OLE .xls/.doc,
  plaintext) still no-op and defer to the downstream parser.
This commit is contained in:
Waleed
2026-06-27 12:34:37 -07:00
committed by GitHub
parent b8a197b99c
commit 37962a05b2
5 changed files with 409 additions and 7 deletions
+3
View File
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
import mammoth from 'mammoth'
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('DocxParser')
@@ -37,6 +38,8 @@ export class DocxParser implements FileParser {
throw new Error('Empty buffer provided')
}
assertOoxmlArchiveWithinLimits(buffer)
try {
const result = await mammoth.extractRawText({ buffer })
+3
View File
@@ -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('PptxParser')
@@ -35,6 +36,8 @@ export class PptxParser implements FileParser {
throw new Error('Empty buffer provided')
}
assertOoxmlArchiveWithinLimits(buffer)
let parseOfficeAsync
try {
const officeParser = await import('officeparser')
+10 -7
View File
@@ -1,8 +1,10 @@
import { existsSync } from 'fs'
import { readFile } from 'fs/promises'
import { createLogger } from '@sim/logger'
import * as XLSX from 'xlsx'
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('XlsxParser')
@@ -16,6 +18,10 @@ const CONFIG = {
}
export class XlsxParser implements FileParser {
/**
* Read the file into a buffer and delegate to {@link parseBuffer} so the
* decompression-bomb guard runs before SheetJS inflates the workbook.
*/
async parseFile(filePath: string): Promise<FileParseResult> {
try {
if (!filePath) {
@@ -28,13 +34,8 @@ export class XlsxParser implements FileParser {
logger.info(`Parsing XLSX file: ${filePath}`)
// Read with streaming option for large files
const workbook = XLSX.readFile(filePath, {
dense: true, // Use dense mode for better memory efficiency
sheetStubs: false, // Don't create stub cells
})
return this.processWorkbook(workbook)
const buffer = await readFile(filePath)
return this.parseBuffer(buffer)
} catch (error) {
logger.error('XLSX file parsing error:', error)
throw new Error(`Failed to parse XLSX file: ${(error as Error).message}`)
@@ -52,6 +53,8 @@ export class XlsxParser implements FileParser {
throw new Error('Empty buffer provided')
}
assertOoxmlArchiveWithinLimits(buffer)
const workbook = XLSX.read(buffer, {
type: 'buffer',
dense: true, // Use dense mode for better memory efficiency
+123
View File
@@ -0,0 +1,123 @@
/**
* @vitest-environment node
*/
import JSZip from 'jszip'
import { describe, expect, it } from 'vitest'
import {
assertOoxmlArchiveWithinLimits,
type OoxmlSizeLimits,
ZipBombError,
} from '@/lib/file-parsers/zip-guard'
const HIGH_LIMITS: OoxmlSizeLimits = {
maxTotalUncompressedBytes: 1024 * 1024 * 1024,
maxCompressionRatio: 10_000,
ratioCheckFloorBytes: 1024 * 1024 * 1024,
}
async function buildZip(
entries: Record<string, string>,
options: { comment?: string } = {}
): Promise<Buffer> {
const zip = new JSZip()
for (const [name, content] of Object.entries(entries)) {
zip.file(name, content)
}
return zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
comment: options.comment,
})
}
describe('assertOoxmlArchiveWithinLimits', () => {
it('accepts a well-formed archive within limits', async () => {
const buffer = await buildZip({ 'word/document.xml': '<xml>hello world</xml>' })
expect(() => assertOoxmlArchiveWithinLimits(buffer, HIGH_LIMITS)).not.toThrow()
})
it('rejects an archive whose declared expanded size exceeds the absolute cap', async () => {
const buffer = await buildZip({ 'xl/worksheets/sheet1.xml': 'A'.repeat(200_000) })
expect(() =>
assertOoxmlArchiveWithinLimits(buffer, {
maxTotalUncompressedBytes: 100_000,
maxCompressionRatio: 10_000,
ratioCheckFloorBytes: 1024 * 1024 * 1024,
})
).toThrow(ZipBombError)
})
it('rejects an archive whose compression ratio exceeds the limit', async () => {
const buffer = await buildZip({ 'xl/worksheets/sheet1.xml': 'A'.repeat(200_000) })
expect(() =>
assertOoxmlArchiveWithinLimits(buffer, {
maxTotalUncompressedBytes: 1024 * 1024 * 1024,
maxCompressionRatio: 5,
ratioCheckFloorBytes: 1000,
})
).toThrow(ZipBombError)
})
it('does not flag a small but highly compressible archive below the ratio floor', async () => {
const buffer = await buildZip({ 'xl/worksheets/sheet1.xml': 'A'.repeat(200_000) })
expect(() =>
assertOoxmlArchiveWithinLimits(buffer, {
maxTotalUncompressedBytes: 1024 * 1024 * 1024,
maxCompressionRatio: 5,
ratioCheckFloorBytes: 1024 * 1024 * 1024,
})
).not.toThrow()
})
it('sums declared sizes across multiple entries', async () => {
const buffer = await buildZip({
'a.xml': 'A'.repeat(60_000),
'b.xml': 'B'.repeat(60_000),
})
expect(() =>
assertOoxmlArchiveWithinLimits(buffer, {
maxTotalUncompressedBytes: 100_000,
maxCompressionRatio: 10_000,
ratioCheckFloorBytes: 1024 * 1024 * 1024,
})
).toThrow(ZipBombError)
})
it('accepts a well-formed archive that carries a trailing comment', async () => {
const buffer = await buildZip(
{ 'word/document.xml': '<xml>hello</xml>' },
{ comment: 'generated by test' }
)
expect(() => assertOoxmlArchiveWithinLimits(buffer, HIGH_LIMITS)).not.toThrow()
})
it('fails closed for a ZIP-shaped buffer whose central directory is unparseable', () => {
const buffer = Buffer.alloc(64)
buffer.writeUInt32LE(0x04034b50, 0) // local file header signature, no valid EOCD
expect(() => assertOoxmlArchiveWithinLimits(buffer)).toThrow(ZipBombError)
})
it('rejects a decoy EOCD signature that does not validate against the buffer tail', async () => {
const realZip = await buildZip({ 'xl/worksheets/sheet1.xml': 'A'.repeat(200_000) })
// A decoy EOCD (zeroed central directory) appended after the genuine archive
// would, without tail validation, redirect the guard to an empty directory
// and undercount the real entries.
const decoy = Buffer.alloc(64)
decoy.writeUInt32LE(0x06054b50, 0)
const tampered = Buffer.concat([realZip, decoy])
expect(() => assertOoxmlArchiveWithinLimits(tampered)).toThrow(ZipBombError)
})
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()
})
it('no-ops for buffers too small to contain an EOCD record', () => {
expect(() => assertOoxmlArchiveWithinLimits(Buffer.from('PK'))).not.toThrow()
})
it('no-ops for an empty buffer', () => {
expect(() => assertOoxmlArchiveWithinLimits(Buffer.alloc(0))).not.toThrow()
})
})
+270
View File
@@ -0,0 +1,270 @@
import { createLogger } from '@sim/logger'
const logger = createLogger('ZipBombGuard')
/**
* OOXML documents (xlsx/docx/pptx) are ZIP archives. Decompression libraries
* (SheetJS, mammoth, officeparser) inflate every entry and build the full
* in-memory object graph before any application-level size cap applies. A
* crafted "zip bomb" — highly repetitive XML that deflates ~100-1000x — can sit
* comfortably under the compressed-input limit yet expand to many gigabytes,
* exhausting the worker and crashing the process with an OOM.
*
* This guard inspects the ZIP central directory (which records each entry's
* declared uncompressed size) and rejects archives whose total expanded size or
* compression ratio exceeds a safe threshold — without decompressing anything.
*/
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50
const EOCD_SIGNATURE = 0x06054b50
const ZIP64_EOCD_LOCATOR_SIGNATURE = 0x07064b50
const ZIP64_EOCD_SIGNATURE = 0x06064b50
const CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50
const ZIP64_EXTRA_FIELD_ID = 0x0001
const EOCD_MIN_SIZE = 22
const ZIP64_EOCD_LOCATOR_SIZE = 20
const CENTRAL_DIRECTORY_HEADER_MIN_SIZE = 46
const MAX_EOCD_COMMENT_SIZE = 0xffff
const UINT32_SENTINEL = 0xffffffff
const UINT16_SENTINEL = 0xffff
export interface OoxmlSizeLimits {
/** Hard ceiling on the summed declared uncompressed size of all entries. */
maxTotalUncompressedBytes: number
/** Maximum allowed expanded:compressed ratio across the whole archive. */
maxCompressionRatio: number
/** The ratio check only applies once the expanded size exceeds this floor, so small files are never flagged. */
ratioCheckFloorBytes: number
}
const ONE_GIBIBYTE = 1024 * 1024 * 1024
const ONE_HUNDRED_MEBIBYTES = 100 * 1024 * 1024
/**
* Defaults sized against the 100 MB compressed-input cap of the parse pipeline.
* A legitimate Office document stays well under 1 GiB expanded; the bombs
* described in the threat model expand to multiple gigabytes.
*/
export const DEFAULT_OOXML_SIZE_LIMITS: OoxmlSizeLimits = {
maxTotalUncompressedBytes: ONE_GIBIBYTE,
maxCompressionRatio: 150,
ratioCheckFloorBytes: ONE_HUNDRED_MEBIBYTES,
}
export class ZipBombError extends Error {
constructor(message: string) {
super(message)
this.name = 'ZipBombError'
}
}
/**
* Whether the buffer is shaped like a ZIP archive — i.e. begins with a local
* file header (the leading signature of every non-empty ZIP, and thus every
* OOXML document) or with the EOCD signature of an empty archive. Used to fail
* closed: a ZIP-shaped buffer the guard cannot parse must be rejected rather
* than handed to a decompression library.
*/
function isZipShaped(buffer: Buffer): boolean {
if (buffer.length < 4) {
return false
}
const signature = buffer.readUInt32LE(0)
return signature === LOCAL_FILE_HEADER_SIGNATURE || signature === EOCD_SIGNATURE
}
/**
* Locate the End Of Central Directory record by scanning backwards from the end
* of the buffer (it sits within the trailing 22 + comment bytes). A candidate
* is only accepted when its declared comment length places the record exactly
* at the buffer tail, so a decoy EOCD signature planted in the comment region
* cannot redirect the guard to a smaller, attacker-chosen central directory.
*/
function findEocdOffset(buffer: Buffer): number {
const minStart = Math.max(0, buffer.length - EOCD_MIN_SIZE - MAX_EOCD_COMMENT_SIZE)
for (let offset = buffer.length - EOCD_MIN_SIZE; offset >= minStart; offset--) {
if (buffer.readUInt32LE(offset) !== EOCD_SIGNATURE) {
continue
}
const commentLength = buffer.readUInt16LE(offset + 20)
if (offset + EOCD_MIN_SIZE + commentLength === buffer.length) {
return offset
}
}
return -1
}
interface CentralDirectoryLocation {
offset: number
entryCount: number
}
/**
* Resolve the central directory offset and entry count, following the ZIP64
* end-of-central-directory chain when the 32-bit fields are saturated.
*/
function locateCentralDirectory(
buffer: Buffer,
eocdOffset: number
): CentralDirectoryLocation | null {
let entryCount = buffer.readUInt16LE(eocdOffset + 10)
let cdOffset = buffer.readUInt32LE(eocdOffset + 16)
const needsZip64 = entryCount === UINT16_SENTINEL || cdOffset === UINT32_SENTINEL
if (needsZip64) {
const locatorOffset = eocdOffset - ZIP64_EOCD_LOCATOR_SIZE
if (locatorOffset < 0 || buffer.readUInt32LE(locatorOffset) !== ZIP64_EOCD_LOCATOR_SIGNATURE) {
return null
}
const zip64EocdOffset = Number(buffer.readBigUInt64LE(locatorOffset + 8))
if (
zip64EocdOffset < 0 ||
zip64EocdOffset + 56 > buffer.length ||
buffer.readUInt32LE(zip64EocdOffset) !== ZIP64_EOCD_SIGNATURE
) {
return null
}
entryCount = Number(buffer.readBigUInt64LE(zip64EocdOffset + 32))
cdOffset = Number(buffer.readBigUInt64LE(zip64EocdOffset + 48))
}
if (cdOffset < 0 || cdOffset > buffer.length) {
return null
}
return { offset: cdOffset, entryCount }
}
/**
* Read an entry's declared uncompressed size, preferring the ZIP64 extra field
* when the 32-bit central-directory field is saturated. The saturated 64-bit
* values appear in the extra field in a fixed order with the uncompressed size
* first, so it is always the leading 8 bytes of the ZIP64 field.
*/
function readUncompressedSize(
buffer: Buffer,
headerOffset: number,
fileNameLength: number,
extraFieldLength: number
): number {
const uncompressedSize = buffer.readUInt32LE(headerOffset + 24)
if (uncompressedSize !== UINT32_SENTINEL) {
return uncompressedSize
}
const extraStart = headerOffset + CENTRAL_DIRECTORY_HEADER_MIN_SIZE + fileNameLength
const extraEnd = extraStart + extraFieldLength
let cursor = extraStart
while (cursor + 4 <= extraEnd) {
const fieldId = buffer.readUInt16LE(cursor)
const fieldSize = buffer.readUInt16LE(cursor + 2)
const dataStart = cursor + 4
if (fieldId === ZIP64_EXTRA_FIELD_ID && dataStart + 8 <= extraEnd) {
return Number(buffer.readBigUInt64LE(dataStart))
}
cursor = dataStart + fieldSize
}
return uncompressedSize
}
/**
* Sum the declared uncompressed size of every central-directory entry. Returns
* `null` when the buffer is not a parseable ZIP archive (e.g. legacy binary
* `.xls`/`.doc`, or a misidentified plaintext file) so the caller can defer to
* the downstream parser. Stops early once the running total exceeds the limit.
*/
function sumDeclaredUncompressedSize(buffer: Buffer, abortAboveBytes: number): number | null {
if (buffer.length < EOCD_MIN_SIZE) {
return null
}
const eocdOffset = findEocdOffset(buffer)
if (eocdOffset < 0) {
return null
}
const location = locateCentralDirectory(buffer, eocdOffset)
if (!location) {
return null
}
let total = 0
let cursor = location.offset
for (let entry = 0; entry < location.entryCount; entry++) {
if (cursor + CENTRAL_DIRECTORY_HEADER_MIN_SIZE > buffer.length) {
return null
}
if (buffer.readUInt32LE(cursor) !== CENTRAL_DIRECTORY_HEADER_SIGNATURE) {
return null
}
const fileNameLength = buffer.readUInt16LE(cursor + 28)
const extraFieldLength = buffer.readUInt16LE(cursor + 30)
const commentLength = buffer.readUInt16LE(cursor + 32)
total += readUncompressedSize(buffer, cursor, fileNameLength, extraFieldLength)
if (total > abortAboveBytes) {
return total
}
cursor += CENTRAL_DIRECTORY_HEADER_MIN_SIZE + fileNameLength + extraFieldLength + commentLength
}
return total
}
/**
* Reject an OOXML archive whose declared expanded size or compression ratio
* exceeds safe bounds, before any decompression library materializes it.
*
* Fails closed: a ZIP-shaped buffer whose central directory cannot be parsed is
* rejected rather than passed through, so a malformed archive that a downstream
* library still inflates cannot bypass the guard. Genuinely non-ZIP inputs
* (legacy OLE `.xls`/`.doc`, misidentified plaintext) no-op and defer to the
* downstream parser's own validation and fallbacks.
*/
export function assertOoxmlArchiveWithinLimits(
buffer: Buffer,
limits: OoxmlSizeLimits = DEFAULT_OOXML_SIZE_LIMITS
): void {
const totalUncompressed = sumDeclaredUncompressedSize(buffer, limits.maxTotalUncompressedBytes)
if (totalUncompressed === null) {
if (isZipShaped(buffer)) {
logger.warn('Rejected ZIP-shaped archive: central directory could not be parsed', {
compressedBytes: buffer.length,
})
throw new ZipBombError(
'Unable to inspect ZIP central directory; refusing to parse an unverifiable ZIP-shaped archive'
)
}
return
}
if (totalUncompressed > limits.maxTotalUncompressedBytes) {
logger.warn('Rejected OOXML archive: declared expanded size exceeds limit', {
totalUncompressed,
maxTotalUncompressedBytes: limits.maxTotalUncompressedBytes,
compressedBytes: buffer.length,
})
throw new ZipBombError(
`Decompressed size (${totalUncompressed} bytes) exceeds the maximum allowed ${limits.maxTotalUncompressedBytes} bytes`
)
}
const ratio = totalUncompressed / Math.max(buffer.length, 1)
if (totalUncompressed > limits.ratioCheckFloorBytes && ratio > limits.maxCompressionRatio) {
logger.warn('Rejected OOXML archive: compression ratio exceeds limit', {
totalUncompressed,
compressedBytes: buffer.length,
ratio,
maxCompressionRatio: limits.maxCompressionRatio,
})
throw new ZipBombError(
`Compression ratio (${ratio.toFixed(1)}x) exceeds the maximum allowed ${limits.maxCompressionRatio}x`
)
}
}