Skip to content

Commit e14f56c

Browse files
committed
improvement(files): extend the OOXML size limits to the client previews
1 parent 33fe043 commit e14f56c

7 files changed

Lines changed: 151 additions & 18 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { cn } from '@sim/emcn'
55
import { createLogger } from '@sim/logger'
66
import { toError } from '@sim/utils/errors'
77
import { sanitizeRenderedHyperlinks, stripEmbeddedFrames } from '@/lib/core/security/url-safety'
8+
import { assertOoxmlPreviewWithinLimits } from '@/lib/file-parsers/ooxml-preview-guard'
89
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
910
import { PREVIEW_LOADING_OVERLAY, PreviewError, resolvePreviewError } from './preview-shared'
1011
import { PreviewToolbar } from './preview-toolbar'
@@ -195,16 +196,18 @@ export const DocxPreview = memo(function DocxPreview({
195196
useEffect(() => {
196197
if (!containerRef.current || !fileData) return
197198

199+
const data = fileData
198200
let cancelled = false
199201

200202
async function render() {
201203
try {
202204
setRendering(true)
205+
await assertOoxmlPreviewWithinLimits(data)
203206
const { renderAsync } = await import('docx-preview')
204207
if (cancelled || !containerRef.current) return
205208
setRenderError(null)
206209
containerRef.current.innerHTML = ''
207-
await renderAsync(fileData, containerRef.current, undefined, {
210+
await renderAsync(data, containerRef.current, undefined, {
208211
inWrapper: true,
209212
ignoreWidth: false,
210213
ignoreHeight: false,

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Chip } from '@sim/emcn'
55
import { createLogger } from '@sim/logger'
66
import { toError } from '@sim/utils/errors'
77
import type { WorkBook } from 'xlsx'
8+
import { assertOoxmlPreviewWithinLimits } from '@/lib/file-parsers/ooxml-preview-guard'
89
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
910
import { DataTable } from './data-table'
1011
import { PreviewError, PreviewLoadingFrame, resolvePreviewError } from './preview-shared'
@@ -46,6 +47,7 @@ export const XlsxPreview = memo(function XlsxPreview({
4647
async function parse() {
4748
try {
4849
setRenderError(null)
50+
await assertOoxmlPreviewWithinLimits(data)
4951
const XLSX = await import('xlsx')
5052
const workbook = XLSX.read(new Uint8Array(data), { type: 'array' })
5153
if (!cancelled) {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Shared size ceilings for OOXML (docx/xlsx/pptx) archives, applied both by the
3+
* server-side parse guard ({@link ./zip-guard}) and the browser preview guard
4+
* ({@link ./ooxml-preview-guard}). The downstream parsers build a full in-memory
5+
* DOM/object graph many times the XML size, so a small archive that expands past
6+
* these bounds can exhaust the shared server process or the viewer's browser tab.
7+
*
8+
* This module is dependency-free and browser-safe on purpose, so both the Node
9+
* guard and the client previews resolve the same numbers from one source.
10+
*/
11+
12+
/** Hard ceiling on the summed declared uncompressed size of all entries. */
13+
export const MAX_OOXML_TOTAL_UNCOMPRESSED_BYTES = 150 * 1024 * 1024
14+
15+
/** Hard ceiling on any single entry's declared uncompressed size. */
16+
export const MAX_OOXML_ENTRY_UNCOMPRESSED_BYTES = 64 * 1024 * 1024
17+
18+
export class ZipBombError extends Error {
19+
constructor(message: string) {
20+
super(message)
21+
this.name = 'ZipBombError'
22+
}
23+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import JSZip from 'jszip'
5+
import { describe, expect, it } from 'vitest'
6+
import { ZipBombError } from '@/lib/file-parsers/ooxml-limits'
7+
import { assertOoxmlPreviewWithinLimits } from '@/lib/file-parsers/ooxml-preview-guard'
8+
9+
async function buildZip(entries: Record<string, string>): Promise<ArrayBuffer> {
10+
const zip = new JSZip()
11+
for (const [name, content] of Object.entries(entries)) {
12+
zip.file(name, content)
13+
}
14+
const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
15+
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
16+
}
17+
18+
const TINY_LIMITS = {
19+
maxTotalUncompressedBytes: 1024 * 1024,
20+
maxEntryUncompressedBytes: 100_000,
21+
}
22+
23+
describe('assertOoxmlPreviewWithinLimits', () => {
24+
it('accepts an archive within the limits', async () => {
25+
const data = await buildZip({ 'word/document.xml': '<w:document/>' })
26+
await expect(assertOoxmlPreviewWithinLimits(data, TINY_LIMITS)).resolves.toBeUndefined()
27+
})
28+
29+
it('rejects an archive whose single part exceeds the per-entry limit', async () => {
30+
const data = await buildZip({ 'word/document.xml': 'A'.repeat(200_000) })
31+
await expect(assertOoxmlPreviewWithinLimits(data, TINY_LIMITS)).rejects.toBeInstanceOf(
32+
ZipBombError
33+
)
34+
})
35+
36+
it('rejects an archive whose summed parts exceed the total limit', async () => {
37+
const data = await buildZip({
38+
'a.xml': 'A'.repeat(60_000),
39+
'b.xml': 'B'.repeat(60_000),
40+
'c.xml': 'C'.repeat(60_000),
41+
})
42+
await expect(
43+
assertOoxmlPreviewWithinLimits(data, {
44+
maxTotalUncompressedBytes: 100_000,
45+
maxEntryUncompressedBytes: 1024 * 1024,
46+
})
47+
).rejects.toBeInstanceOf(ZipBombError)
48+
})
49+
50+
it('accepts an ordinary document under the shared default limits', async () => {
51+
const data = await buildZip({
52+
'[Content_Types].xml': '<?xml version="1.0"?><Types/>',
53+
'word/document.xml': `<w:document>${'text '.repeat(5000)}</w:document>`,
54+
})
55+
await expect(assertOoxmlPreviewWithinLimits(data)).resolves.toBeUndefined()
56+
})
57+
})
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { JSZipObject } from 'jszip'
2+
import {
3+
MAX_OOXML_ENTRY_UNCOMPRESSED_BYTES,
4+
MAX_OOXML_TOTAL_UNCOMPRESSED_BYTES,
5+
ZipBombError,
6+
} from '@/lib/file-parsers/ooxml-limits'
7+
8+
export interface OoxmlPreviewLimits {
9+
maxTotalUncompressedBytes: number
10+
maxEntryUncompressedBytes: number
11+
}
12+
13+
/**
14+
* The central directory records each entry's declared uncompressed size, so
15+
* `JSZip.loadAsync` exposes it without inflating anything.
16+
*/
17+
function readDeclaredUncompressedSize(entry: JSZipObject): number | undefined {
18+
const data = (entry as JSZipObject & { _data?: { uncompressedSize?: number } })._data
19+
const size = data?.uncompressedSize
20+
return typeof size === 'number' && Number.isFinite(size) ? size : undefined
21+
}
22+
23+
/**
24+
* Reject an OOXML archive whose declared uncompressed size exceeds the shared
25+
* OOXML bounds, before a browser preview (docx-preview, SheetJS) inflates it and
26+
* exhausts the tab. `JSZip.loadAsync` reads the central directory without
27+
* inflating any entry, so this inspects the declared sizes cheaply.
28+
*
29+
* Declared sizes are attacker-controlled, so a lying archive can still slip past;
30+
* the browser has no bounded synchronous inflate to verify them the way the
31+
* server guard does. This catches the honest bombs, the realistic preview case.
32+
*/
33+
export async function assertOoxmlPreviewWithinLimits(
34+
data: ArrayBuffer | Uint8Array,
35+
{
36+
maxTotalUncompressedBytes = MAX_OOXML_TOTAL_UNCOMPRESSED_BYTES,
37+
maxEntryUncompressedBytes = MAX_OOXML_ENTRY_UNCOMPRESSED_BYTES,
38+
}: Partial<OoxmlPreviewLimits> = {}
39+
): Promise<void> {
40+
const JSZip = (await import('jszip')).default
41+
const zip = await JSZip.loadAsync(data)
42+
43+
let total = 0
44+
for (const entry of Object.values(zip.files)) {
45+
if (entry.dir) continue
46+
const size = readDeclaredUncompressedSize(entry)
47+
if (size === undefined) continue
48+
total += size
49+
if (size > maxEntryUncompressedBytes || total > maxTotalUncompressedBytes) {
50+
throw new ZipBombError('This file is too large to preview safely')
51+
}
52+
}
53+
}

apps/sim/lib/file-parsers/zip-guard.test.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,8 @@
33
*/
44
import JSZip from 'jszip'
55
import { describe, expect, it } from 'vitest'
6-
import {
7-
assertOoxmlArchiveWithinLimits,
8-
type OoxmlSizeLimits,
9-
ZipBombError,
10-
} from '@/lib/file-parsers/zip-guard'
6+
import { ZipBombError } from '@/lib/file-parsers/ooxml-limits'
7+
import { assertOoxmlArchiveWithinLimits, type OoxmlSizeLimits } from '@/lib/file-parsers/zip-guard'
118

129
const HIGH_LIMITS: OoxmlSizeLimits = {
1310
maxTotalUncompressedBytes: 1024 * 1024 * 1024,

apps/sim/lib/file-parsers/zip-guard.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { inflateRawSync } from 'zlib'
22
import { createLogger } from '@sim/logger'
3+
import {
4+
MAX_OOXML_ENTRY_UNCOMPRESSED_BYTES,
5+
MAX_OOXML_TOTAL_UNCOMPRESSED_BYTES,
6+
ZipBombError,
7+
} from '@/lib/file-parsers/ooxml-limits'
38

49
const logger = createLogger('ZipBombGuard')
510

@@ -50,8 +55,6 @@ export interface OoxmlSizeLimits {
5055
}
5156

5257
const ONE_HUNDRED_MEBIBYTES = 100 * 1024 * 1024
53-
const ONE_HUNDRED_FIFTY_MEBIBYTES = 150 * 1024 * 1024
54-
const SIXTY_FOUR_MEBIBYTES = 64 * 1024 * 1024
5558

5659
/**
5760
* The downstream parsers (mammoth, SheetJS, officeparser) build a full in-memory
@@ -60,22 +63,17 @@ const SIXTY_FOUR_MEBIBYTES = 64 * 1024 * 1024
6063
* parses a second time for HTML. The old 1 GiB ceiling let a ~3.5 MB archive
6164
* expand past what the process could hold and OOM it. The total and per-entry
6265
* caps here keep a single parse's peak within a modest container's budget while
63-
* still admitting all but pathologically large documents.
66+
* still admitting all but pathologically large documents. The size ceilings are
67+
* shared with the browser preview guard via {@link ./ooxml-limits}; the ratio
68+
* heuristic is server-only.
6469
*/
6570
export const DEFAULT_OOXML_SIZE_LIMITS: OoxmlSizeLimits = {
66-
maxTotalUncompressedBytes: ONE_HUNDRED_FIFTY_MEBIBYTES,
67-
maxEntryUncompressedBytes: SIXTY_FOUR_MEBIBYTES,
71+
maxTotalUncompressedBytes: MAX_OOXML_TOTAL_UNCOMPRESSED_BYTES,
72+
maxEntryUncompressedBytes: MAX_OOXML_ENTRY_UNCOMPRESSED_BYTES,
6873
maxCompressionRatio: 150,
6974
ratioCheckFloorBytes: ONE_HUNDRED_MEBIBYTES,
7075
}
7176

72-
export class ZipBombError extends Error {
73-
constructor(message: string) {
74-
super(message)
75-
this.name = 'ZipBombError'
76-
}
77-
}
78-
7977
/**
8078
* Whether the buffer is shaped like a ZIP archive — i.e. begins with a local
8179
* file header (the leading signature of every non-empty ZIP, and thus every

0 commit comments

Comments
 (0)