Skip to content

Commit 54f281b

Browse files
committed
fix(files): bound HTML parser input before building the DOM
Rejects HTML documents above a byte and markup-token budget before cheerio builds the document tree, and wires the rejection into the parse route's fail-closed path alongside the existing YAML one.
1 parent 6c6d8a5 commit 54f281b

3 files changed

Lines changed: 150 additions & 2 deletions

File tree

apps/sim/app/api/files/parse/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
1313
import { sanitizeUrlForLog } from '@/lib/core/utils/logging'
1414
import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
1515
import { isSupportedFileType, parseFile } from '@/lib/file-parsers'
16+
import { isHtmlComplexityError } from '@/lib/file-parsers/html-parser'
1617
import { isYamlComplexityError } from '@/lib/file-parsers/yaml-parser'
1718
import { isUsingCloudStorage, StorageService } from '@/lib/uploads'
1819
import { uploadExecutionFile } from '@/lib/uploads/contexts/execution'
@@ -1047,6 +1048,7 @@ async function handleGenericTextBuffer(
10471048
// Fail closed on a resource-exhaustion rejection instead of silently
10481049
// storing the crafted document as raw text.
10491050
if (isYamlComplexityError(parserError)) throw parserError
1051+
if (isHtmlComplexityError(parserError)) throw parserError
10501052

10511053
logger.warn('Specialized parser failed, falling back to generic parsing:', parserError)
10521054
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { HtmlComplexityError, HtmlParser } from '@/lib/file-parsers/html-parser'
6+
7+
const parser = new HtmlParser()
8+
9+
describe('HtmlParser', () => {
10+
describe('resource limits', () => {
11+
it('rejects a document above the input byte cap', async () => {
12+
const sparse = Buffer.concat([
13+
Buffer.from('<html><body><p>'),
14+
Buffer.alloc(32 * 1024 * 1024, 0x61),
15+
Buffer.from('</p></body></html>'),
16+
])
17+
18+
await expect(parser.parseBuffer(sparse)).rejects.toThrow(
19+
/above the maximum of 33554432 bytes/
20+
)
21+
})
22+
23+
it('rejects a tag-dense document above the markup-token cap', async () => {
24+
const dense = Buffer.from(`<html><body>${'<p>a</p>'.repeat(300_000)}</body></html>`)
25+
26+
const error = await parser.parseBuffer(dense).catch((e) => e)
27+
28+
expect(error).toBeInstanceOf(HtmlComplexityError)
29+
expect(error.message).toMatch(/exceeds the maximum of 500000 markup tokens/)
30+
})
31+
32+
it('accepts a byte-heavy document whose markup stays under the token cap', async () => {
33+
const paragraph = `<p>${'word '.repeat(200)}</p>`
34+
const buffer = Buffer.from(`<html><body>${paragraph.repeat(2000)}</body></html>`)
35+
36+
const result = await parser.parseBuffer(buffer)
37+
38+
expect(result.content).toContain('word')
39+
})
40+
41+
/**
42+
* Deep nesting overflows the stack inside cheerio's own recursive `.text()`,
43+
* which the caps cannot pre-empt. A `RangeError` is catchable, so it must
44+
* surface as a rejected promise rather than take the process down.
45+
*/
46+
it('surfaces deeply nested markup as a catchable error, not a crash', async () => {
47+
const depth = 15_000
48+
const buffer = Buffer.from(
49+
`<html><body>${'<div>'.repeat(depth)}deep${'</div>'.repeat(depth)}</body></html>`
50+
)
51+
52+
await expect(parser.parseBuffer(buffer)).rejects.toThrow(/Failed to parse HTML buffer/)
53+
})
54+
})
55+
56+
describe('extraction', () => {
57+
it('extracts structured text, headings, links, and metadata', async () => {
58+
const buffer = Buffer.from(
59+
`<html><head><title>Doc</title><meta name="description" content="About"></head>` +
60+
`<body><h1>Title</h1><p>Body text</p>` +
61+
`<ul><li>one</li><li>two</li></ul>` +
62+
`<table><tr><th>h</th></tr><tr><td>c</td></tr></table>` +
63+
`<a href="https://example.com">Example</a>` +
64+
`<script>alert(1)</script></body></html>`
65+
)
66+
67+
const result = await parser.parseBuffer(buffer)
68+
69+
expect(result.metadata?.title).toBe('Doc')
70+
expect(result.metadata?.metaDescription).toBe('About')
71+
expect(result.content).toContain('Title')
72+
expect(result.content).toContain('Body text')
73+
expect(result.content).toContain('• one')
74+
expect(result.content).toContain('| h |')
75+
expect(result.content).toContain('Example (https://example.com)')
76+
expect(result.content).not.toContain('alert(1)')
77+
expect(result.metadata?.headings).toEqual([{ level: 1, text: 'Title' }])
78+
expect(result.metadata?.links).toEqual([{ text: 'Example', href: 'https://example.com' }])
79+
expect(result.metadata?.listCount).toBe(1)
80+
expect(result.metadata?.tableCount).toBe(1)
81+
})
82+
})
83+
})

apps/sim/lib/file-parsers/html-parser.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,72 @@
11
import { readFile } from 'fs/promises'
22
import { createLogger } from '@sim/logger'
3+
import { getErrorMessage } from '@sim/utils/errors'
34
import * as cheerio from 'cheerio'
45
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
56
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
67

78
const logger = createLogger('HtmlParser')
89

10+
/**
11+
* `cheerio.load` retains ~530 bytes of DOM per markup token (`<`) — measured on
12+
* cheerio 1.1.2 at 0.2M/1M/2M tokens (101/504/1008 MB), flat across all three —
13+
* so this bounds one document's tree at roughly 256 MB.
14+
*/
15+
const MAX_HTML_MARKUP_TOKENS = 500_000
16+
17+
/**
18+
* Backstop for markup sparse enough to pass the token cap: bounds the UTF-16
19+
* copy `buffer.toString` allocates and the text nodes the DOM keeps.
20+
*/
21+
const MAX_HTML_INPUT_BYTES = 32 * 1024 * 1024
22+
23+
const MARKUP_TOKEN_BYTE = 0x3c
24+
25+
/**
26+
* Raised when a document exceeds the limits above, so an input rejected on
27+
* resource grounds is not reported as a malformed file.
28+
*/
29+
export class HtmlComplexityError extends Error {
30+
constructor(message: string) {
31+
super(message)
32+
this.name = 'HtmlComplexityError'
33+
}
34+
}
35+
36+
export function isHtmlComplexityError(error: unknown): error is HtmlComplexityError {
37+
return error instanceof HtmlComplexityError
38+
}
39+
40+
function exceedsMarkupTokenLimit(buffer: Buffer): boolean {
41+
let count = 0
42+
let index = buffer.indexOf(MARKUP_TOKEN_BYTE)
43+
44+
while (index !== -1) {
45+
if (++count > MAX_HTML_MARKUP_TOKENS) return true
46+
index = buffer.indexOf(MARKUP_TOKEN_BYTE, index + 1)
47+
}
48+
49+
return false
50+
}
51+
52+
/**
53+
* `cheerio.load` builds the entire parse5 tree before returning, so an outsized
54+
* document has to be rejected on the buffer, before the string copy.
55+
*/
56+
function assertHtmlWithinLimits(buffer: Buffer): void {
57+
if (buffer.length > MAX_HTML_INPUT_BYTES) {
58+
throw new HtmlComplexityError(
59+
`HTML document is ${buffer.length} bytes, above the maximum of ${MAX_HTML_INPUT_BYTES} bytes`
60+
)
61+
}
62+
63+
if (exceedsMarkupTokenLimit(buffer)) {
64+
throw new HtmlComplexityError(
65+
`HTML document exceeds the maximum of ${MAX_HTML_MARKUP_TOKENS} markup tokens`
66+
)
67+
}
68+
}
69+
970
export class HtmlParser implements FileParser {
1071
async parseFile(filePath: string): Promise<FileParseResult> {
1172
try {
@@ -17,11 +78,13 @@ export class HtmlParser implements FileParser {
1778
return this.parseBuffer(buffer)
1879
} catch (error) {
1980
logger.error('HTML file error:', error)
20-
throw new Error(`Failed to parse HTML file: ${(error as Error).message}`)
81+
throw new Error(`Failed to parse HTML file: ${getErrorMessage(error, 'Unknown error')}`)
2182
}
2283
}
2384

2485
async parseBuffer(buffer: Buffer): Promise<FileParseResult> {
86+
assertHtmlWithinLimits(buffer)
87+
2588
try {
2689
logger.info('Parsing HTML buffer, size:', buffer.length)
2790

@@ -73,7 +136,7 @@ export class HtmlParser implements FileParser {
73136
}
74137
} catch (error) {
75138
logger.error('HTML buffer parsing error:', error)
76-
throw new Error(`Failed to parse HTML buffer: ${(error as Error).message}`)
139+
throw new Error(`Failed to parse HTML buffer: ${getErrorMessage(error, 'Unknown error')}`)
77140
}
78141
}
79142

0 commit comments

Comments
 (0)