diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx index e3cfffb4dda..0e6e3602842 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx @@ -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' @@ -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, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx index 5ff5c26b3f4..98be34a560c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx @@ -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' @@ -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) { diff --git a/apps/sim/lib/file-parsers/ooxml-limits.ts b/apps/sim/lib/file-parsers/ooxml-limits.ts new file mode 100644 index 00000000000..8d4c4d71612 --- /dev/null +++ b/apps/sim/lib/file-parsers/ooxml-limits.ts @@ -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' + } +} diff --git a/apps/sim/lib/file-parsers/ooxml-preview-guard.test.ts b/apps/sim/lib/file-parsers/ooxml-preview-guard.test.ts new file mode 100644 index 00000000000..387ed423a3b --- /dev/null +++ b/apps/sim/lib/file-parsers/ooxml-preview-guard.test.ts @@ -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): Promise { + 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': '' }) + 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': '', + 'word/document.xml': `${'text '.repeat(5000)}`, + }) + await expect(assertOoxmlPreviewWithinLimits(data)).resolves.toBeUndefined() + }) +}) diff --git a/apps/sim/lib/file-parsers/ooxml-preview-guard.ts b/apps/sim/lib/file-parsers/ooxml-preview-guard.ts new file mode 100644 index 00000000000..fd601f81b13 --- /dev/null +++ b/apps/sim/lib/file-parsers/ooxml-preview-guard.ts @@ -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 = {} +): Promise { + 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') + } + } +} diff --git a/apps/sim/lib/file-parsers/zip-guard.test.ts b/apps/sim/lib/file-parsers/zip-guard.test.ts index 9d5ba1b0ab4..6ed2d923abb 100644 --- a/apps/sim/lib/file-parsers/zip-guard.test.ts +++ b/apps/sim/lib/file-parsers/zip-guard.test.ts @@ -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, diff --git a/apps/sim/lib/file-parsers/zip-guard.ts b/apps/sim/lib/file-parsers/zip-guard.ts index 81b506eb227..1ba027c8cd6 100644 --- a/apps/sim/lib/file-parsers/zip-guard.ts +++ b/apps/sim/lib/file-parsers/zip-guard.ts @@ -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') @@ -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 @@ -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