mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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,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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user