Skip to content

Commit 87b7b4b

Browse files
authored
improvement(files): harden untrusted document preview and parsing (#6420)
* improvement(files): harden the docx preview renderer * fix(files): tighten the OOXML archive size guard against parser OOM * fix(files): apply the per-entry OOXML cap to every part, not just XML names
1 parent a2ad4b6 commit 87b7b4b

5 files changed

Lines changed: 157 additions & 23 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react'
44
import { cn } from '@sim/emcn'
55
import { createLogger } from '@sim/logger'
66
import { toError } from '@sim/utils/errors'
7-
import { sanitizeRenderedHyperlinks } from '@/lib/core/security/url-safety'
7+
import { sanitizeRenderedHyperlinks, stripEmbeddedFrames } from '@/lib/core/security/url-safety'
88
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
99
import { PREVIEW_LOADING_OVERLAY, PreviewError, resolvePreviewError } from './preview-shared'
1010
import { PreviewToolbar } from './preview-toolbar'
@@ -208,9 +208,11 @@ export const DocxPreview = memo(function DocxPreview({
208208
inWrapper: true,
209209
ignoreWidth: false,
210210
ignoreHeight: false,
211+
renderAltChunks: false,
211212
})
212213
if (!cancelled && containerRef.current) {
213214
sanitizeRenderedHyperlinks(containerRef.current)
215+
stripEmbeddedFrames(containerRef.current)
214216
applyPostRenderStyling()
215217
setHasRenderedPreview(true)
216218
setDocumentRenderVersion((version) => version + 1)

apps/sim/lib/core/security/url-safety.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
* @vitest-environment jsdom
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { isAllowedExternalUrl, sanitizeRenderedHyperlinks } from '@/lib/core/security/url-safety'
5+
import {
6+
isAllowedExternalUrl,
7+
sanitizeRenderedHyperlinks,
8+
stripEmbeddedFrames,
9+
} from '@/lib/core/security/url-safety'
610

711
describe('isAllowedExternalUrl', () => {
812
it('allows http, https, and mailto URLs', () => {
@@ -59,3 +63,21 @@ describe('sanitizeRenderedHyperlinks', () => {
5963
expect(anchor?.getAttribute('rel')).toBe('noopener noreferrer')
6064
})
6165
})
66+
67+
describe('stripEmbeddedFrames', () => {
68+
it('removes a srcdoc iframe and leaves sibling content intact', () => {
69+
const container = document.createElement('div')
70+
container.innerHTML =
71+
'<p>page</p><iframe srcdoc="&lt;script&gt;fetch(1)&lt;/script&gt;"></iframe>'
72+
stripEmbeddedFrames(container)
73+
expect(container.querySelector('iframe')).toBeNull()
74+
expect(container.querySelector('p')?.textContent).toBe('page')
75+
})
76+
77+
it('removes object and embed plugin elements', () => {
78+
const container = document.createElement('div')
79+
container.innerHTML = '<object data="x"></object><embed src="x" />'
80+
stripEmbeddedFrames(container)
81+
expect(container.querySelectorAll('object, embed').length).toBe(0)
82+
})
83+
})

apps/sim/lib/core/security/url-safety.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
2-
* URL safety utilities for external hyperlinks/media in untrusted document content
3-
* (PPTX, DOCX, and other previews rendered into the app origin).
2+
* Safety utilities for untrusted document content rendered into the app origin
3+
* (PPTX, DOCX, and other previews).
44
*/
55

66
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:'])
@@ -35,3 +35,14 @@ export function sanitizeRenderedHyperlinks(root: ParentNode): void {
3535
anchor.removeAttribute('href')
3636
}
3737
}
38+
39+
/**
40+
* Removes embedded browsing contexts from rendered document content. A renderer-injected
41+
* `<iframe srcdoc>` inherits the app origin, so any script it carries runs with the
42+
* victim's session. Defense in depth — renderers are also configured not to emit frames.
43+
*/
44+
export function stripEmbeddedFrames(root: ParentNode): void {
45+
for (const element of root.querySelectorAll('iframe, object, embed')) {
46+
element.remove()
47+
}
48+
}

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

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111

1212
const HIGH_LIMITS: OoxmlSizeLimits = {
1313
maxTotalUncompressedBytes: 1024 * 1024 * 1024,
14+
maxEntryUncompressedBytes: 1024 * 1024 * 1024,
1415
maxCompressionRatio: 10_000,
1516
ratioCheckFloorBytes: 1024 * 1024 * 1024,
1617
}
@@ -98,6 +99,7 @@ describe('assertOoxmlArchiveWithinLimits', () => {
9899
expect(() =>
99100
assertOoxmlArchiveWithinLimits(buffer, {
100101
maxTotalUncompressedBytes: 100_000,
102+
maxEntryUncompressedBytes: 1024 * 1024 * 1024,
101103
maxCompressionRatio: 10_000,
102104
ratioCheckFloorBytes: 1024 * 1024 * 1024,
103105
})
@@ -109,6 +111,7 @@ describe('assertOoxmlArchiveWithinLimits', () => {
109111
expect(() =>
110112
assertOoxmlArchiveWithinLimits(buffer, {
111113
maxTotalUncompressedBytes: 1024 * 1024 * 1024,
114+
maxEntryUncompressedBytes: 1024 * 1024 * 1024,
112115
maxCompressionRatio: 5,
113116
ratioCheckFloorBytes: 1000,
114117
})
@@ -120,6 +123,7 @@ describe('assertOoxmlArchiveWithinLimits', () => {
120123
expect(() =>
121124
assertOoxmlArchiveWithinLimits(buffer, {
122125
maxTotalUncompressedBytes: 1024 * 1024 * 1024,
126+
maxEntryUncompressedBytes: 1024 * 1024 * 1024,
123127
maxCompressionRatio: 5,
124128
ratioCheckFloorBytes: 1024 * 1024 * 1024,
125129
})
@@ -131,13 +135,63 @@ describe('assertOoxmlArchiveWithinLimits', () => {
131135
'a.xml': 'A'.repeat(60_000),
132136
'b.xml': 'B'.repeat(60_000),
133137
})
138+
// Each entry (60 KB) is under the per-entry cap; only the summed total trips
139+
// the limit, so this must fail on the total branch, not the per-entry one.
134140
expect(() =>
135141
assertOoxmlArchiveWithinLimits(buffer, {
136142
maxTotalUncompressedBytes: 100_000,
143+
maxEntryUncompressedBytes: 1024 * 1024 * 1024,
137144
maxCompressionRatio: 10_000,
138145
ratioCheckFloorBytes: 1024 * 1024 * 1024,
139146
})
140-
).toThrow(ZipBombError)
147+
).toThrow(/Decompressed size .* exceeds the maximum allowed/)
148+
})
149+
150+
it('rejects an archive whose largest single entry exceeds the per-entry cap', async () => {
151+
const buffer = await buildZip({ 'word/document.xml': 'A'.repeat(200_000) })
152+
expect(() =>
153+
assertOoxmlArchiveWithinLimits(buffer, {
154+
maxTotalUncompressedBytes: 1024 * 1024 * 1024,
155+
maxEntryUncompressedBytes: 100_000,
156+
maxCompressionRatio: 10_000,
157+
ratioCheckFloorBytes: 1024 * 1024 * 1024,
158+
})
159+
).toThrow(/single entry's decompressed size .* exceeds the maximum allowed/)
160+
})
161+
162+
it('applies the per-entry cap to a non-.xml part resolved through OPC relationships', async () => {
163+
// The main document part is resolved via relationship target, not a fixed
164+
// path, so a bomb under a `.bin` name is still DOM-parsed — the cap must not
165+
// exempt it on filename.
166+
const buffer = await buildZip({ 'word/document.bin': 'A'.repeat(200_000) })
167+
expect(() =>
168+
assertOoxmlArchiveWithinLimits(buffer, {
169+
maxTotalUncompressedBytes: 1024 * 1024 * 1024,
170+
maxEntryUncompressedBytes: 100_000,
171+
maxCompressionRatio: 10_000,
172+
ratioCheckFloorBytes: 1024 * 1024 * 1024,
173+
})
174+
).toThrow(/single entry's decompressed size .* exceeds the maximum allowed/)
175+
})
176+
177+
it('rejects a single part larger than the 64 MiB per-entry default before any parser sees it', async () => {
178+
// A part declaring 70 MiB expanded passes the old 1 GiB ceiling but drives
179+
// the parser's DOM past a modest heap. `underDeclareSizes` is reused in the
180+
// over-declaring direction to set the declared size without allocating it.
181+
const honest = await buildZip({ 'word/document.xml': 'A'.repeat(200_000) })
182+
const oversized = underDeclareSizes(honest, 70 * 1024 * 1024)
183+
expect(() => assertOoxmlArchiveWithinLimits(oversized)).toThrow(
184+
/single entry's decompressed size .* exceeds the maximum allowed 67108864 bytes/
185+
)
186+
})
187+
188+
it('accepts an ordinary document under the default limits', async () => {
189+
const buffer = await buildZip({
190+
'[Content_Types].xml': '<?xml version="1.0"?><Types/>',
191+
'_rels/.rels': '<?xml version="1.0"?><Relationships/>',
192+
'word/document.xml': `<w:document>${'text '.repeat(5000)}</w:document>`,
193+
})
194+
expect(() => assertOoxmlArchiveWithinLimits(buffer)).not.toThrow()
141195
})
142196

143197
it('accepts a well-formed archive that carries a trailing comment', async () => {
@@ -248,6 +302,7 @@ describe('assertOoxmlArchiveWithinLimits', () => {
248302
expect(() =>
249303
assertOoxmlArchiveWithinLimits(buffer, {
250304
maxTotalUncompressedBytes: 100_000,
305+
maxEntryUncompressedBytes: 1024 * 1024 * 1024,
251306
maxCompressionRatio: 10_000,
252307
ratioCheckFloorBytes: 1024 * 1024 * 1024,
253308
})

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

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ const logger = createLogger('ZipBombGuard')
1212
* exhausting the worker and crashing the process with an OOM.
1313
*
1414
* This guard inspects the ZIP central directory (which records each entry's
15-
* declared uncompressed size) and rejects archives whose total expanded size or
16-
* compression ratio exceeds a safe threshold — without decompressing anything.
15+
* declared uncompressed size) and rejects archives whose total expanded size,
16+
* largest single entry, or compression ratio exceeds a safe threshold — without
17+
* decompressing anything.
1718
*/
1819

1920
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50
@@ -40,22 +41,30 @@ const DATA_DESCRIPTOR_FLAG = 0x0008
4041
export interface OoxmlSizeLimits {
4142
/** Hard ceiling on the summed declared uncompressed size of all entries. */
4243
maxTotalUncompressedBytes: number
44+
/** Hard ceiling on any single entry's declared uncompressed size — the parser materializes an individual part (e.g. `document.xml`) into a DOM, or base64-embeds media, at a footprint many times the part size. */
45+
maxEntryUncompressedBytes: number
4346
/** Maximum allowed expanded:compressed ratio across the whole archive. */
4447
maxCompressionRatio: number
4548
/** The ratio check only applies once the expanded size exceeds this floor, so small files are never flagged. */
4649
ratioCheckFloorBytes: number
4750
}
4851

49-
const ONE_GIBIBYTE = 1024 * 1024 * 1024
5052
const ONE_HUNDRED_MEBIBYTES = 100 * 1024 * 1024
53+
const ONE_HUNDRED_FIFTY_MEBIBYTES = 150 * 1024 * 1024
54+
const SIXTY_FOUR_MEBIBYTES = 64 * 1024 * 1024
5155

5256
/**
53-
* Defaults sized against the 100 MB compressed-input cap of the parse pipeline.
54-
* A legitimate Office document stays well under 1 GiB expanded; the bombs
55-
* described in the threat model expand to multiple gigabytes.
57+
* The downstream parsers (mammoth, SheetJS, officeparser) build a full in-memory
58+
* DOM/object graph whose peak heap is many times the XML size — measured at
59+
* ~900 MB resident for 32 MB of expanded WordprocessingML, and mammoth then
60+
* parses a second time for HTML. The old 1 GiB ceiling let a ~3.5 MB archive
61+
* expand past what the process could hold and OOM it. The total and per-entry
62+
* caps here keep a single parse's peak within a modest container's budget while
63+
* still admitting all but pathologically large documents.
5664
*/
5765
export const DEFAULT_OOXML_SIZE_LIMITS: OoxmlSizeLimits = {
58-
maxTotalUncompressedBytes: ONE_GIBIBYTE,
66+
maxTotalUncompressedBytes: ONE_HUNDRED_FIFTY_MEBIBYTES,
67+
maxEntryUncompressedBytes: SIXTY_FOUR_MEBIBYTES,
5968
maxCompressionRatio: 150,
6069
ratioCheckFloorBytes: ONE_HUNDRED_MEBIBYTES,
6170
}
@@ -212,11 +221,25 @@ function readCentralDirectoryEntry(
212221
return { compressionMethod, compressedSize, uncompressedSize, localHeaderOffset }
213222
}
214223

224+
interface DeclaredSizeStats {
225+
/** Summed declared uncompressed size across the contiguous run of records. */
226+
total: number
227+
/** Largest single entry's declared uncompressed size. */
228+
largestEntry: number
229+
}
230+
215231
/**
216-
* Sum the declared uncompressed size of every central-directory entry. Returns
217-
* `null` when the buffer is not a parseable ZIP archive (e.g. legacy binary
218-
* `.xls`/`.doc`, or a misidentified plaintext file) so the caller can defer to
219-
* the downstream parser. Stops early once the running total exceeds the limit.
232+
* Sum the declared uncompressed size of every central-directory entry and track
233+
* the largest single entry. Returns `null` when the buffer is not a parseable
234+
* ZIP archive (e.g. legacy binary `.xls`/`.doc`, or a misidentified plaintext
235+
* file) so the caller can defer to the downstream parser. Stops early once the
236+
* running total, or a single entry, exceeds the corresponding limit.
237+
*
238+
* The per-entry cap covers every entry, not just `.xml` parts: an OOXML part is
239+
* resolved through OPC relationship targets (mammoth's `officeDocument`
240+
* relationship, with `word/document.xml` only a fallback), so an arbitrarily
241+
* named part is still deserialized into a DOM — a name-based exemption would let
242+
* a bomb sail through under a `.bin` target.
220243
*
221244
* Like {@link readZipCentralDirectoryStats}, this charges the CONTIGUOUS run of
222245
* records rather than the EOCD's declared count. JSZip's `readCentralDir` loops
@@ -225,7 +248,10 @@ function readCentralDirectoryEntry(
225248
* would otherwise hide honestly-large entries from this cap while the parser
226249
* still expanded them.
227250
*/
228-
function sumDeclaredUncompressedSize(buffer: Buffer, abortAboveBytes: number): number | null {
251+
function sumDeclaredUncompressedSize(
252+
buffer: Buffer,
253+
limits: OoxmlSizeLimits
254+
): DeclaredSizeStats | null {
229255
if (buffer.length < EOCD_MIN_SIZE) {
230256
return null
231257
}
@@ -241,6 +267,7 @@ function sumDeclaredUncompressedSize(buffer: Buffer, abortAboveBytes: number): n
241267
}
242268

243269
let total = 0
270+
let largestEntry = 0
244271
let counted = 0
245272
let cursor = location.offset
246273
while (
@@ -251,14 +278,18 @@ function sumDeclaredUncompressedSize(buffer: Buffer, abortAboveBytes: number): n
251278
const extraFieldLength = buffer.readUInt16LE(cursor + 30)
252279
const commentLength = buffer.readUInt16LE(cursor + 32)
253280

254-
total += readCentralDirectoryEntry(
281+
const entryBytes = readCentralDirectoryEntry(
255282
buffer,
256283
cursor,
257284
fileNameLength,
258285
extraFieldLength
259286
).uncompressedSize
260-
if (total > abortAboveBytes) {
261-
return total
287+
total += entryBytes
288+
if (entryBytes > largestEntry) {
289+
largestEntry = entryBytes
290+
}
291+
if (total > limits.maxTotalUncompressedBytes || entryBytes > limits.maxEntryUncompressedBytes) {
292+
return { total, largestEntry }
262293
}
263294

264295
counted += 1
@@ -271,7 +302,7 @@ function sumDeclaredUncompressedSize(buffer: Buffer, abortAboveBytes: number): n
271302
return null
272303
}
273304

274-
return total
305+
return { total, largestEntry }
275306
}
276307

277308
/**
@@ -471,8 +502,8 @@ export function assertOoxmlArchiveWithinLimits(
471502
buffer: Buffer,
472503
limits: OoxmlSizeLimits = DEFAULT_OOXML_SIZE_LIMITS
473504
): void {
474-
const totalUncompressed = sumDeclaredUncompressedSize(buffer, limits.maxTotalUncompressedBytes)
475-
if (totalUncompressed === null) {
505+
const declared = sumDeclaredUncompressedSize(buffer, limits)
506+
if (declared === null) {
476507
if (isZipShaped(buffer)) {
477508
logger.warn('Rejected ZIP-shaped archive: central directory could not be parsed', {
478509
compressedBytes: buffer.length,
@@ -484,6 +515,19 @@ export function assertOoxmlArchiveWithinLimits(
484515
return
485516
}
486517

518+
const { total: totalUncompressed, largestEntry } = declared
519+
520+
if (largestEntry > limits.maxEntryUncompressedBytes) {
521+
logger.warn('Rejected OOXML archive: a single entry exceeds the per-entry limit', {
522+
largestEntry,
523+
maxEntryUncompressedBytes: limits.maxEntryUncompressedBytes,
524+
compressedBytes: buffer.length,
525+
})
526+
throw new ZipBombError(
527+
`A single entry's decompressed size (${largestEntry} bytes) exceeds the maximum allowed ${limits.maxEntryUncompressedBytes} bytes`
528+
)
529+
}
530+
487531
if (totalUncompressed > limits.maxTotalUncompressedBytes) {
488532
logger.warn('Rejected OOXML archive: declared expanded size exceeds limit', {
489533
totalUncompressed,

0 commit comments

Comments
 (0)