Skip to content

Commit e0043ad

Browse files
waleedlatif1claude
andcommitted
fix(parsers): bound ODF whitespace expansion and accept absolute OPC targets
An ODF text:s element passed its text:c count straight to String.repeat, so one tiny element could request a multi-gigabyte allocation. Each run is now capped at 100 spaces, every emitted piece is charged against a 16 MiB document budget that throws complexity_limit before later parts are inflated, and both walkers assert the assembled text against the same ceiling. The line-end trim that followed was quadratic on a long whitespace run — a document inside the budget could still hang it — so it is now a linear per-line trimEnd. OPC relationship targets may be package-absolute (/ppt/slides/slide1.xml); the PPTX resolver joined them onto the base directory and skipped those parts. A leading slash now resolves from the package root under the same ppt/ clamp. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent d2e8e7c commit e0043ad

5 files changed

Lines changed: 193 additions & 42 deletions

File tree

apps/sim/lib/file-parsers/odf-text.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import JSZip, { type JSZipObject } from 'jszip'
55
import { afterEach, describe, expect, it, vi } from 'vitest'
66
import type { FileParserError } from '@/lib/file-parsers/errors'
77
import { extractOpenDocumentText } from '@/lib/file-parsers/odf-text'
8-
import { MAX_OFFICE_XML_PART_BYTES } from '@/lib/file-parsers/office-text'
8+
import { MAX_OFFICE_TEXT_BYTES, MAX_OFFICE_XML_PART_BYTES } from '@/lib/file-parsers/office-text'
99

1010
const NS =
1111
'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:presentation="urn:oasis:names:tc:opendocument:xmlns:presentation:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/"'
@@ -104,6 +104,48 @@ describe('extractOpenDocumentText', () => {
104104
expect(result).toBe('[Table]\n| Out A | Intro In 1 / In 2 / In 3 |\n[/Table]')
105105
})
106106

107+
it('caps a text:s run instead of allocating what text:c asks for', async () => {
108+
const buffer = await text(`<text:p>A<text:s text:c="1000000000"/>B</text:p>`)
109+
110+
expect(await extractOpenDocumentText(buffer)).toBe(`A${' '.repeat(100)}B`)
111+
})
112+
113+
it('handles a long whitespace run inside the budget in linear time', async () => {
114+
const spaces = '<text:s text:c="100"/>'.repeat(2_000)
115+
const buffer = await text(`<text:p>x${spaces}y<text:line-break/>z</text:p>`)
116+
117+
const started = performance.now()
118+
const result = await extractOpenDocumentText(buffer)
119+
120+
expect(result).toBe(`x${' '.repeat(200_000)}y\nz`)
121+
expect(performance.now() - started).toBeLessThan(5_000)
122+
}, 60_000)
123+
124+
it('rejects a document whose expanded text exceeds the ceiling', async () => {
125+
const spaces = '<text:s text:c="100"/>'.repeat(Math.ceil(MAX_OFFICE_TEXT_BYTES / 100) + 1)
126+
const buffer = await text(`<text:p>x${spaces}y</text:p>`)
127+
128+
await expect(extractOpenDocumentText(buffer)).rejects.toMatchObject<FileParserError>({
129+
code: 'complexity_limit',
130+
})
131+
})
132+
133+
it('stops walking at the ceiling before inflating later parts', async () => {
134+
const spaces = '<text:s text:c="100"/>'.repeat(Math.ceil(MAX_OFFICE_TEXT_BYTES / 100) + 1)
135+
const buffer = await buildOdf(`<office:text><text:p>x${spaces}y</text:p></office:text>`, {
136+
'Object 1/content.xml': `<?xml version="1.0"?><office:document-content ${NS}><office:body><office:text><text:p>Embedded</text:p></office:text></office:body></office:document-content>`,
137+
})
138+
const zip = await JSZip.loadAsync(buffer)
139+
const embedded = zip.file('Object 1/content.xml') as JSZipObject
140+
const inflate = vi.spyOn(embedded, 'async')
141+
vi.spyOn(JSZip, 'loadAsync').mockResolvedValueOnce(zip)
142+
143+
await expect(extractOpenDocumentText(buffer)).rejects.toMatchObject<FileParserError>({
144+
code: 'complexity_limit',
145+
})
146+
expect(inflate).not.toHaveBeenCalled()
147+
})
148+
107149
it('rejects an archive without content.xml as invalid_format', async () => {
108150
const zip = new JSZip()
109151
zip.file('mimetype', 'application/vnd.oasis.opendocument.text', { compression: 'STORE' })

apps/sim/lib/file-parsers/odf-text.ts

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import JSZip from 'jszip'
22
import { FileParserError } from '@/lib/file-parsers/errors'
33
import {
4+
assertTextWithinLimit,
5+
chargeTextBudget,
46
childElements,
57
collapseWhitespace,
68
findFirst,
@@ -13,6 +15,8 @@ import {
1315
readXmlPart,
1416
TABLE_CLOSE,
1517
TABLE_OPEN,
18+
type TextBudget,
19+
trimLineEnds,
1620
type XmlElement,
1721
} from '@/lib/file-parsers/office-text'
1822
import type { FileParseOptions } from '@/lib/file-parsers/types'
@@ -49,6 +53,9 @@ const SKIPPED_PRESENTATION_CLASSES = new Set(['header', 'footer', 'date-time', '
4953
/** Bounds `table:number-columns-repeated`, which spreadsheets inflate to 1024. */
5054
const MAX_REPEATED_COLUMNS = 32
5155

56+
/** Bounds one `text:s` run; `text:c` is attacker-controlled and would otherwise size an allocation. */
57+
const MAX_SPACE_RUN = 100
58+
5259
const MAX_LIST_INDENT = 3
5360

5461
interface WalkState {
@@ -57,10 +64,18 @@ interface WalkState {
5764
pendingNotes: string[]
5865
/** Inside a table cell, nested tables flatten to text rather than emitting markers. */
5966
inCell: boolean
67+
/** Shared across the document so cells and note bodies count toward one ceiling. */
68+
budget: TextBudget
69+
}
70+
71+
function newState(budget: TextBudget, inCell = false): WalkState {
72+
return { blocks: [], pendingNotes: [], inCell, budget }
6073
}
6174

62-
function newState(inCell = false): WalkState {
63-
return { blocks: [], pendingNotes: [], inCell }
75+
/** Records a piece of emitted text against the document ceiling before it is kept. */
76+
function emit(state: WalkState, pieces: string[], text: string): void {
77+
chargeTextBudget(state.budget, text.length)
78+
pieces.push(text)
6479
}
6580

6681
function isSkipped(element: XmlElement): boolean {
@@ -80,22 +95,23 @@ function inlineText(element: XmlElement, state: WalkState): string {
8095
const pieces: string[] = []
8196
for (const child of element.children) {
8297
if (child.type === 'text') {
83-
pieces.push(child.data)
98+
emit(state, pieces, child.data)
8499
continue
85100
}
86101
if (!isXmlElement(child) || isSkipped(child)) continue
87102

88103
switch (child.name) {
89104
case 'text:s': {
90105
const count = Number.parseInt(child.attribs['text:c'] ?? '1', 10)
91-
pieces.push(' '.repeat(Number.isFinite(count) && count > 0 ? count : 1))
106+
const bounded = Number.isFinite(count) && count > 0 ? Math.min(count, MAX_SPACE_RUN) : 1
107+
emit(state, pieces, ' '.repeat(bounded))
92108
break
93109
}
94110
case 'text:tab':
95-
pieces.push('\t')
111+
emit(state, pieces, '\t')
96112
break
97113
case 'text:line-break':
98-
pieces.push('\n')
114+
emit(state, pieces, '\n')
99115
break
100116
case 'draw:frame': {
101117
const image = imageFrameText(child, state)
@@ -106,7 +122,7 @@ function inlineText(element: XmlElement, state: WalkState): string {
106122
const citation = findFirst(child, 'text:note-citation')
107123
const body = findFirst(child, 'text:note-body')
108124
const label = citation ? collapseWhitespace(inlineText(citation, state)) : ''
109-
const bodyText = body ? collapseWhitespace(blockText(body)) : ''
125+
const bodyText = body ? collapseWhitespace(blockText(body, state.budget)) : ''
110126
if (label) pieces.push(`[${label}]`)
111127
if (bodyText) state.pendingNotes.push(label ? `[${label}] ${bodyText}` : bodyText)
112128
break
@@ -119,8 +135,8 @@ function inlineText(element: XmlElement, state: WalkState): string {
119135
}
120136

121137
/** Renders a container's block children to a single string, for cells and note bodies. */
122-
function blockText(container: XmlElement, inCell = false): string {
123-
const state = newState(inCell)
138+
function blockText(container: XmlElement, budget: TextBudget, inCell = false): string {
139+
const state = newState(budget, inCell)
124140
walkChildren(container, state, 0)
125141
return [...state.blocks, ...state.pendingNotes].join('\n')
126142
}
@@ -132,9 +148,7 @@ function flushNotes(state: WalkState): void {
132148
}
133149

134150
function emitParagraph(element: XmlElement, state: WalkState, heading: boolean): void {
135-
const text = inlineText(element, state)
136-
.replace(/[ \t]+\n/g, '\n')
137-
.trim()
151+
const text = trimLineEnds(inlineText(element, state)).trim()
138152
if (text) {
139153
state.blocks.push(heading ? `\n${text}\n` : text)
140154
}
@@ -167,8 +181,8 @@ function emitList(list: XmlElement, state: WalkState, depth: number): void {
167181
}
168182
}
169183

170-
function cellText(cell: XmlElement): string {
171-
return collapseWhitespace(blockText(cell, true))
184+
function cellText(cell: XmlElement, budget: TextBudget): string {
185+
return collapseWhitespace(blockText(cell, budget, true))
172186
}
173187

174188
function repeatCount(element: XmlElement, attribute: string, cap: number): number {
@@ -179,15 +193,15 @@ function repeatCount(element: XmlElement, attribute: string, cap: number): numbe
179193
return Math.min(parsed, cap)
180194
}
181195

182-
function tableRows(container: XmlElement, rows: string[]): void {
196+
function tableRows(container: XmlElement, rows: string[], state: WalkState): void {
183197
for (const child of childElements(container)) {
184198
if (isSkipped(child)) continue
185199
switch (child.name) {
186200
case 'table:table-row': {
187201
const cells: string[] = []
188202
for (const cell of childElements(child)) {
189203
if (cell.name !== 'table:table-cell' && cell.name !== 'table:covered-table-cell') continue
190-
const text = cellText(cell)
204+
const text = cellText(cell, state.budget)
191205
const repeats = repeatCount(cell, 'table:number-columns-repeated', MAX_REPEATED_COLUMNS)
192206
for (let i = 0; i < repeats; i++) cells.push(text)
193207
}
@@ -197,7 +211,7 @@ function tableRows(container: XmlElement, rows: string[]): void {
197211
case 'table:table-header-rows':
198212
case 'table:table-rows':
199213
case 'table:table-row-group':
200-
tableRows(child, rows)
214+
tableRows(child, rows, state)
201215
break
202216
default:
203217
break
@@ -206,34 +220,34 @@ function tableRows(container: XmlElement, rows: string[]): void {
206220
}
207221

208222
/** Every non-empty cell of a table in reading order, for a table nested inside a cell. */
209-
function flattenedCells(container: XmlElement, cells: string[]): void {
223+
function flattenedCells(container: XmlElement, cells: string[], state: WalkState): void {
210224
for (const child of childElements(container)) {
211225
if (isSkipped(child)) continue
212226
if (child.name === 'table:table-row') {
213227
for (const cell of childElements(child)) {
214228
if (cell.name !== 'table:table-cell' && cell.name !== 'table:covered-table-cell') continue
215-
const text = cellText(cell)
229+
const text = cellText(cell, state.budget)
216230
if (text) cells.push(text)
217231
}
218232
} else if (
219233
child.name === 'table:table-header-rows' ||
220234
child.name === 'table:table-rows' ||
221235
child.name === 'table:table-row-group'
222236
) {
223-
flattenedCells(child, cells)
237+
flattenedCells(child, cells, state)
224238
}
225239
}
226240
}
227241

228242
function emitTable(table: XmlElement, state: WalkState): void {
229243
if (state.inCell) {
230244
const cells: string[] = []
231-
flattenedCells(table, cells)
245+
flattenedCells(table, cells, state)
232246
if (cells.length > 0) state.blocks.push(cells.join(' / '))
233247
return
234248
}
235249
const rows: string[] = []
236-
tableRows(table, rows)
250+
tableRows(table, rows, state)
237251
if (rows.length > 0) {
238252
state.blocks.push('', TABLE_OPEN, ...rows, TABLE_CLOSE, '')
239253
}
@@ -251,7 +265,7 @@ function imageFrameText(frame: XmlElement, state: WalkState): string | null {
251265
}
252266

253267
function emitNotes(notes: XmlElement, state: WalkState): void {
254-
const body = collapseWhitespace(blockText(notes))
268+
const body = collapseWhitespace(blockText(notes, state.budget))
255269
if (body) state.blocks.push(NOTES_MARKER, body)
256270
}
257271

@@ -299,11 +313,11 @@ function walkChildren(container: XmlElement, state: WalkState, depth: number): v
299313
}
300314
}
301315

302-
function contentBlocks(contentXml: string): string[] {
316+
function contentBlocks(contentXml: string, budget: TextBudget): string[] {
303317
const document = parseXml(contentXml)
304318
const body = findFirst(document, 'office:body')
305319
if (!body) return []
306-
const state = newState()
320+
const state = newState(budget)
307321
walkChildren(body, state, 0)
308322
flushNotes(state)
309323
return state.blocks
@@ -321,7 +335,8 @@ function embeddedContentParts(zip: JSZip): string[] {
321335
/**
322336
* Extracts structured text from an OpenDocument text or presentation package.
323337
* The caller must already have applied the archive size guard; each XML part is
324-
* additionally bounded by {@link readXmlPart}. An archive without `content.xml`
338+
* additionally bounded by {@link readXmlPart} and the assembled text by
339+
* {@link assertTextWithinLimit}. An archive without `content.xml`
325340
* is not an OpenDocument file at all and is rejected as `invalid_format`; a
326341
* present but textless body yields an empty string for the caller to classify.
327342
*/
@@ -339,14 +354,15 @@ export async function extractOpenDocumentText(
339354
)
340355
}
341356

357+
const budget: TextBudget = { used: 0 }
342358
const sections: string[] = []
343359
for (const path of [CONTENT_PART, ...embeddedContentParts(zip)]) {
344360
const xml = await readXmlPart(zip, path)
345361
options.signal?.throwIfAborted()
346362
if (xml === null) continue
347-
const blocks = contentBlocks(xml)
363+
const blocks = contentBlocks(xml, budget)
348364
if (blocks.length > 0) sections.push(joinBlocks(blocks), '')
349365
}
350366

351-
return joinBlocks(sections)
367+
return assertTextWithinLimit(joinBlocks(sections))
352368
}

apps/sim/lib/file-parsers/office-text.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,40 @@ function declaredUncompressedSize(entry: JSZipObject): number | undefined {
3636
return typeof size === 'number' && Number.isFinite(size) ? size : undefined
3737
}
3838

39+
/**
40+
* Hard ceiling on the text a walker assembles from one document. The part cap
41+
* bounds the markup, but ODF whitespace and repeat attributes can expand a
42+
* small part many times over, so the output is bounded on its own.
43+
*/
44+
export const MAX_OFFICE_TEXT_BYTES = MAX_OFFICE_XML_PART_BYTES
45+
46+
/** Running total of emitted text, shared by every walk state of one document. */
47+
export interface TextBudget {
48+
used: number
49+
}
50+
51+
export function chargeTextBudget(budget: TextBudget, length: number): void {
52+
budget.used += length
53+
if (budget.used > MAX_OFFICE_TEXT_BYTES) {
54+
throw new FileParserError(
55+
'complexity_limit',
56+
`Document text exceeds the maximum of ${MAX_OFFICE_TEXT_BYTES} bytes`
57+
)
58+
}
59+
}
60+
61+
/** The assembled output must fit the same ceiling once joined. */
62+
export function assertTextWithinLimit(text: string): string {
63+
const bytes = Buffer.byteLength(text, 'utf8')
64+
if (bytes > MAX_OFFICE_TEXT_BYTES) {
65+
throw new FileParserError(
66+
'complexity_limit',
67+
`Document text is ${bytes} bytes, above the maximum of ${MAX_OFFICE_TEXT_BYTES} bytes`
68+
)
69+
}
70+
return text
71+
}
72+
3973
function xmlPartTooLarge(path: string, bytes: number): FileParserError {
4074
return new FileParserError(
4175
'complexity_limit',
@@ -104,6 +138,21 @@ export function imageAltText(raw: string | undefined): string | null {
104138
return `[Image: ${text}]`
105139
}
106140

141+
/**
142+
* Strips trailing spaces and tabs from every line in linear time. The obvious
143+
* `/[ \t]+\n/` is quadratic on a long whitespace run — each position scans
144+
* the run, fails on the newline, and backtracks — which a document inside the
145+
* text budget can still trigger.
146+
*/
147+
export function trimLineEnds(text: string): string {
148+
return text.includes('\n')
149+
? text
150+
.split('\n')
151+
.map((line) => line.trimEnd())
152+
.join('\n')
153+
: text
154+
}
155+
107156
/** Collapses internal whitespace so a cell or list item occupies a single line. */
108157
export function collapseWhitespace(text: string): string {
109158
return text.replace(/\s+/g, ' ').trim()

0 commit comments

Comments
 (0)