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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { cn } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { sanitizeRenderedHyperlinks, stripEmbeddedFrames } from '@/lib/core/security/url-safety'
import { assertOoxmlPreviewWithinLimits } from '@/lib/file-parsers/ooxml-preview-guard'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { PREVIEW_LOADING_OVERLAY, PreviewError, resolvePreviewError } from './preview-shared'
import { PreviewToolbar } from './preview-toolbar'
Expand Down Expand Up @@ -195,16 +196,18 @@ export const DocxPreview = memo(function DocxPreview({
useEffect(() => {
if (!containerRef.current || !fileData) return

const data = fileData
let cancelled = false

async function render() {
try {
setRendering(true)
await assertOoxmlPreviewWithinLimits(data)
const { renderAsync } = await import('docx-preview')
if (cancelled || !containerRef.current) return
setRenderError(null)
containerRef.current.innerHTML = ''
await renderAsync(fileData, containerRef.current, undefined, {
await renderAsync(data, containerRef.current, undefined, {
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Chip } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import type { WorkBook } from 'xlsx'
import { assertOoxmlPreviewWithinLimits } from '@/lib/file-parsers/ooxml-preview-guard'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { DataTable } from './data-table'
import { PreviewError, PreviewLoadingFrame, resolvePreviewError } from './preview-shared'
Expand Down Expand Up @@ -46,6 +47,7 @@ export const XlsxPreview = memo(function XlsxPreview({
async function parse() {
try {
setRenderError(null)
await assertOoxmlPreviewWithinLimits(data)
const XLSX = await import('xlsx')
const workbook = XLSX.read(new Uint8Array(data), { type: 'array' })
if (!cancelled) {
Expand Down
23 changes: 23 additions & 0 deletions apps/sim/lib/file-parsers/ooxml-limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Shared size ceilings for OOXML (docx/xlsx/pptx) archives, applied both by the
* server-side parse guard ({@link ./zip-guard}) and the browser preview guard
* ({@link ./ooxml-preview-guard}). The downstream parsers build a full in-memory
* DOM/object graph many times the XML size, so a small archive that expands past
* these bounds can exhaust the shared server process or the viewer's browser tab.
*
* This module is dependency-free and browser-safe on purpose, so both the Node
* guard and the client previews resolve the same numbers from one source.
*/

/** Hard ceiling on the summed declared uncompressed size of all entries. */
export const MAX_OOXML_TOTAL_UNCOMPRESSED_BYTES = 150 * 1024 * 1024

/** Hard ceiling on any single entry's declared uncompressed size. */
export const MAX_OOXML_ENTRY_UNCOMPRESSED_BYTES = 64 * 1024 * 1024

export class ZipBombError extends Error {
constructor(message: string) {
super(message)
this.name = 'ZipBombError'
}
}
57 changes: 57 additions & 0 deletions apps/sim/lib/file-parsers/ooxml-preview-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* @vitest-environment node
*/
import JSZip from 'jszip'
import { describe, expect, it } from 'vitest'
import { ZipBombError } from '@/lib/file-parsers/ooxml-limits'
import { assertOoxmlPreviewWithinLimits } from '@/lib/file-parsers/ooxml-preview-guard'

async function buildZip(entries: Record<string, string>): Promise<ArrayBuffer> {
const zip = new JSZip()
for (const [name, content] of Object.entries(entries)) {
zip.file(name, content)
}
const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
}

const TINY_LIMITS = {
maxTotalUncompressedBytes: 1024 * 1024,
maxEntryUncompressedBytes: 100_000,
}

describe('assertOoxmlPreviewWithinLimits', () => {
it('accepts an archive within the limits', async () => {
const data = await buildZip({ 'word/document.xml': '<w:document/>' })
await expect(assertOoxmlPreviewWithinLimits(data, TINY_LIMITS)).resolves.toBeUndefined()
})

it('rejects an archive whose single part exceeds the per-entry limit', async () => {
const data = await buildZip({ 'word/document.xml': 'A'.repeat(200_000) })
await expect(assertOoxmlPreviewWithinLimits(data, TINY_LIMITS)).rejects.toBeInstanceOf(
ZipBombError
)
})

it('rejects an archive whose summed parts exceed the total limit', async () => {
const data = await buildZip({
'a.xml': 'A'.repeat(60_000),
'b.xml': 'B'.repeat(60_000),
'c.xml': 'C'.repeat(60_000),
})
await expect(
assertOoxmlPreviewWithinLimits(data, {
maxTotalUncompressedBytes: 100_000,
maxEntryUncompressedBytes: 1024 * 1024,
})
).rejects.toBeInstanceOf(ZipBombError)
})

it('accepts an ordinary document under the shared default limits', async () => {
const data = await buildZip({
'[Content_Types].xml': '<?xml version="1.0"?><Types/>',
'word/document.xml': `<w:document>${'text '.repeat(5000)}</w:document>`,
})
await expect(assertOoxmlPreviewWithinLimits(data)).resolves.toBeUndefined()
})
})
53 changes: 53 additions & 0 deletions apps/sim/lib/file-parsers/ooxml-preview-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { JSZipObject } from 'jszip'
import {
MAX_OOXML_ENTRY_UNCOMPRESSED_BYTES,
MAX_OOXML_TOTAL_UNCOMPRESSED_BYTES,
ZipBombError,
} from '@/lib/file-parsers/ooxml-limits'

export interface OoxmlPreviewLimits {
maxTotalUncompressedBytes: number
maxEntryUncompressedBytes: number
}

/**
* The central directory records each entry's declared uncompressed size, so
* `JSZip.loadAsync` exposes it without inflating anything.
*/
function readDeclaredUncompressedSize(entry: JSZipObject): number | undefined {
const data = (entry as JSZipObject & { _data?: { uncompressedSize?: number } })._data
const size = data?.uncompressedSize
return typeof size === 'number' && Number.isFinite(size) ? size : undefined
}

/**
* Reject an OOXML archive whose declared uncompressed size exceeds the shared
* OOXML bounds, before a browser preview (docx-preview, SheetJS) inflates it and
* exhausts the tab. `JSZip.loadAsync` reads the central directory without
* inflating any entry, so this inspects the declared sizes cheaply.
*
* Declared sizes are attacker-controlled, so a lying archive can still slip past;
* the browser has no bounded synchronous inflate to verify them the way the
* server guard does. This catches the honest bombs, the realistic preview case.
*/
export async function assertOoxmlPreviewWithinLimits(
data: ArrayBuffer | Uint8Array,
{
maxTotalUncompressedBytes = MAX_OOXML_TOTAL_UNCOMPRESSED_BYTES,
maxEntryUncompressedBytes = MAX_OOXML_ENTRY_UNCOMPRESSED_BYTES,
}: Partial<OoxmlPreviewLimits> = {}
): Promise<void> {
const JSZip = (await import('jszip')).default
const zip = await JSZip.loadAsync(data)

let total = 0
for (const entry of Object.values(zip.files)) {
if (entry.dir) continue
const size = readDeclaredUncompressedSize(entry)
if (size === undefined) continue
total += size
if (size > maxEntryUncompressedBytes || total > maxTotalUncompressedBytes) {
throw new ZipBombError('This file is too large to preview safely')
}
}
}
7 changes: 2 additions & 5 deletions apps/sim/lib/file-parsers/zip-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,8 @@
*/
import JSZip from 'jszip'
import { describe, expect, it } from 'vitest'
import {
assertOoxmlArchiveWithinLimits,
type OoxmlSizeLimits,
ZipBombError,
} from '@/lib/file-parsers/zip-guard'
import { ZipBombError } from '@/lib/file-parsers/ooxml-limits'
import { assertOoxmlArchiveWithinLimits, type OoxmlSizeLimits } from '@/lib/file-parsers/zip-guard'

const HIGH_LIMITS: OoxmlSizeLimits = {
maxTotalUncompressedBytes: 1024 * 1024 * 1024,
Expand Down
22 changes: 10 additions & 12 deletions apps/sim/lib/file-parsers/zip-guard.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { inflateRawSync } from 'zlib'
import { createLogger } from '@sim/logger'
import {
MAX_OOXML_ENTRY_UNCOMPRESSED_BYTES,
MAX_OOXML_TOTAL_UNCOMPRESSED_BYTES,
ZipBombError,
} from '@/lib/file-parsers/ooxml-limits'

const logger = createLogger('ZipBombGuard')

Expand Down Expand Up @@ -50,8 +55,6 @@ export interface OoxmlSizeLimits {
}

const ONE_HUNDRED_MEBIBYTES = 100 * 1024 * 1024
const ONE_HUNDRED_FIFTY_MEBIBYTES = 150 * 1024 * 1024
const SIXTY_FOUR_MEBIBYTES = 64 * 1024 * 1024

/**
* The downstream parsers (mammoth, SheetJS, officeparser) build a full in-memory
Expand All @@ -60,22 +63,17 @@ const SIXTY_FOUR_MEBIBYTES = 64 * 1024 * 1024
* parses a second time for HTML. The old 1 GiB ceiling let a ~3.5 MB archive
* expand past what the process could hold and OOM it. The total and per-entry
* caps here keep a single parse's peak within a modest container's budget while
* still admitting all but pathologically large documents.
* still admitting all but pathologically large documents. The size ceilings are
* shared with the browser preview guard via {@link ./ooxml-limits}; the ratio
* heuristic is server-only.
*/
export const DEFAULT_OOXML_SIZE_LIMITS: OoxmlSizeLimits = {
maxTotalUncompressedBytes: ONE_HUNDRED_FIFTY_MEBIBYTES,
maxEntryUncompressedBytes: SIXTY_FOUR_MEBIBYTES,
maxTotalUncompressedBytes: MAX_OOXML_TOTAL_UNCOMPRESSED_BYTES,
maxEntryUncompressedBytes: MAX_OOXML_ENTRY_UNCOMPRESSED_BYTES,
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
Expand Down
Loading