d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
271 lines
9.5 KiB
TypeScript
271 lines
9.5 KiB
TypeScript
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`
|
|
)
|
|
}
|
|
}
|