diff --git a/apps/sim/app/api/files/parse/route.ts b/apps/sim/app/api/files/parse/route.ts index c1bcf0dbea2..8047cea0f0d 100644 --- a/apps/sim/app/api/files/parse/route.ts +++ b/apps/sim/app/api/files/parse/route.ts @@ -13,6 +13,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { isSupportedFileType, parseFile } from '@/lib/file-parsers' +import { isYamlComplexityError } from '@/lib/file-parsers/yaml-parser' import { isUsingCloudStorage, StorageService } from '@/lib/uploads' import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' import { @@ -1043,6 +1044,9 @@ async function handleGenericTextBuffer( } } catch (parserError) { if (isPayloadSizeLimitError(parserError)) throw parserError + // Fail closed on a resource-exhaustion rejection instead of silently + // storing the crafted document as raw text. + if (isYamlComplexityError(parserError)) throw parserError logger.warn('Specialized parser failed, falling back to generic parsing:', parserError) } diff --git a/apps/sim/lib/file-parsers/yaml-parser.test.ts b/apps/sim/lib/file-parsers/yaml-parser.test.ts new file mode 100644 index 00000000000..68464ece162 --- /dev/null +++ b/apps/sim/lib/file-parsers/yaml-parser.test.ts @@ -0,0 +1,127 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + assertYamlWithinLimits, + parseYAMLBuffer, + YamlComplexityError, +} from '@/lib/file-parsers/yaml-parser' + +/** + * Build a chained alias-expansion ("billion laughs") YAML bomb: each level is + * an array that references the previous level `width` times, so the expanded + * node count grows as `width ^ levels` while the source stays tiny. + */ +function buildAliasBomb(levels: number, width: number): string { + const lines: string[] = [`l0: &l0 [${Array(width).fill('"x"').join(',')}]`] + for (let i = 1; i <= levels; i++) { + const refs = Array(width) + .fill(`*l${i - 1}`) + .join(',') + lines.push(`l${i}: &l${i} [${refs}]`) + } + lines.push(`root: [${Array(width).fill(`*l${levels}`).join(',')}]`) + return lines.join('\n') +} + +describe('parseYAMLBuffer', () => { + it('parses a normal YAML document', async () => { + const result = await parseYAMLBuffer( + Buffer.from('name: sim\nlist:\n - a\n - b\nnested:\n key: value\n') + ) + const parsed = JSON.parse(result.content) + expect(parsed).toEqual({ name: 'sim', list: ['a', 'b'], nested: { key: 'value' } }) + expect(result.metadata.type).toBe('yaml') + expect(result.metadata.keys).toEqual(['name', 'list', 'nested']) + expect(result.metadata.depth).toBeGreaterThan(0) + }) + + it('reports depth and array metadata', async () => { + const result = await parseYAMLBuffer(Buffer.from('- 1\n- 2\n- 3\n')) + expect(result.metadata.isArray).toBe(true) + expect(result.metadata.itemCount).toBe(3) + expect(result.metadata.depth).toBe(1) + }) + + it('rejects an alias-expansion bomb before serialization', async () => { + const bomb = buildAliasBomb(9, 10) + expect(Buffer.byteLength(bomb)).toBeLessThan(2048) + await expect(parseYAMLBuffer(Buffer.from(bomb))).rejects.toBeInstanceOf(YamlComplexityError) + }) + + it('rejects a key-amplification bomb (aliased object with a long key)', async () => { + const longKey = 'k'.repeat(2000) + const lines: string[] = [`l0: &l0 {${longKey}: 1}`] + for (let i = 1; i <= 8; i++) { + lines.push( + `l${i}: &l${i} [${Array(10) + .fill(`*l${i - 1}`) + .join(',')}]` + ) + } + lines.push(`root: [${Array(10).fill('*l8').join(',')}]`) + const bomb = lines.join('\n') + expect(Buffer.byteLength(bomb)).toBeLessThan(4096) + await expect(parseYAMLBuffer(Buffer.from(bomb))).rejects.toBeInstanceOf(YamlComplexityError) + }) + + it('surfaces malformed YAML as an Invalid YAML error', async () => { + await expect(parseYAMLBuffer(Buffer.from('key: "unterminated\n'))).rejects.toThrow( + /Invalid YAML/ + ) + }) +}) + +describe('assertYamlWithinLimits', () => { + it('returns the depth of a well-formed structure', () => { + expect(assertYamlWithinLimits({ a: { b: { c: 1 } } })).toBe(3) + expect(assertYamlWithinLimits([1, 2, 3])).toBe(1) + expect(assertYamlWithinLimits('scalar')).toBe(0) + }) + + it('rejects nesting beyond the depth cap', () => { + let deep: unknown = 1 + for (let i = 0; i < 600; i++) deep = { a: deep } + expect(() => assertYamlWithinLimits(deep)).toThrow(YamlComplexityError) + }) + + it('rejects a cyclic structure via the node cap', () => { + const node: Record = {} + node.self = node + expect(() => assertYamlWithinLimits(node)).toThrow(YamlComplexityError) + }) + + it('charges object keys, not just values', () => { + const hugeKey = 'k'.repeat(70 * 1024 * 1024) + expect(() => assertYamlWithinLimits({ [hugeKey]: 1 })).toThrow(YamlComplexityError) + }) + + it('rejects a large plain-text value only when it truly exceeds the size cap', () => { + // ~10 MB of plain ASCII serializes ~1:1 and must be accepted (no false positive). + expect(() => assertYamlWithinLimits({ text: 'a'.repeat(10 * 1024 * 1024) })).not.toThrow() + // ~70 MB exceeds the 64 MB cap and must be rejected. + expect(() => assertYamlWithinLimits({ text: 'a'.repeat(70 * 1024 * 1024) })).toThrow( + YamlComplexityError + ) + }) + + it('charges the true escaped length of escape-heavy strings', () => { + // ~11M control chars each serialize to a six-char \uXXXX escape (~66 MB > 64 MB). + const escapeHeavy = ''.repeat(11 * 1024 * 1024) + expect(() => assertYamlWithinLimits({ text: escapeHeavy })).toThrow(YamlComplexityError) + expect(escapeHeavy.length).toBeLessThan(64 * 1024 * 1024) + }) + + it('charges lone surrogates at their escaped length', () => { + // JSON.stringify escapes a lone surrogate to \uXXXX (six units). ~11M of + // them estimate to ~66 MB (> 64 MB) though the raw length is under the cap. + const loneSurrogates = String.fromCharCode(0xd800).repeat(11 * 1024 * 1024) + expect(loneSurrogates.length).toBeLessThan(64 * 1024 * 1024) + expect(() => assertYamlWithinLimits({ text: loneSurrogates })).toThrow(YamlComplexityError) + // A valid surrogate pair (astral char) is emitted as-is, so ~10M of them + // (~20M code units, ~20 MB) stays well under the cap. + const astral = String.fromCodePoint(0x1f600).repeat(10 * 1024 * 1024) + expect(() => assertYamlWithinLimits({ text: astral })).not.toThrow() + }) +}) diff --git a/apps/sim/lib/file-parsers/yaml-parser.ts b/apps/sim/lib/file-parsers/yaml-parser.ts index 7af7743a296..a0bd297880e 100644 --- a/apps/sim/lib/file-parsers/yaml-parser.ts +++ b/apps/sim/lib/file-parsers/yaml-parser.ts @@ -3,74 +3,226 @@ import * as yaml from 'js-yaml' import type { FileParseResult } from '@/lib/file-parsers/types' /** - * Parse YAML files + * Hard cap on the number of expanded nodes visited while validating a parsed + * YAML document. `yaml.load` resolves aliases into shared references, so the + * in-memory value is a compact DAG, but `JSON.stringify` expands that DAG into + * a full tree — duplicating every shared node. A tiny "billion laughs" alias + * bomb therefore expands to millions/billions of nodes at serialize time. This + * cap (and the byte cap below) bound the traversal so the amplification is + * detected and rejected before it ever reaches `JSON.stringify`. It also stops + * traversal of self-referential (cyclic) YAML anchors. */ -export async function parseYAML(filePath: string): Promise { - const fs = await import('fs/promises') - const content = await fs.readFile(filePath, 'utf-8') +const MAX_YAML_EXPANDED_NODES = 5_000_000 - try { - // Parse YAML to validate and extract structure - const yamlData = yaml.load(content) +/** + * Cap on the estimated serialized (pretty-printed JSON) size of the document. + * Alias expansion inflates output far beyond the input size — a sub-1 KB input + * can serialize to hundreds of MB — so we estimate output bytes during the + * bounded traversal and abort past this limit rather than allocating them. + */ +const MAX_YAML_SERIALIZED_BYTES = 64 * 1024 * 1024 + +/** + * Cap on nesting depth. Guards the depth computation (previously an unbounded + * recursion that also spread large arrays into `Math.max(...array)`, risking a + * stack overflow) and rejects pathologically deep documents. + */ +const MAX_YAML_DEPTH = 500 - // Convert to JSON for consistent processing - const jsonContent = JSON.stringify(yamlData, null, 2) +/** + * Raised when a parsed YAML document exceeds the complexity limits above. + * Distinct from a syntax error so callers can tell a malformed file apart from + * a resource-exhaustion (alias-expansion DoS) attempt. + */ +export class YamlComplexityError extends Error { + constructor(message: string) { + super(message) + this.name = 'YamlComplexityError' + } +} - // Extract metadata about the YAML structure - const metadata = { - type: 'yaml', - isArray: Array.isArray(yamlData), - keys: Array.isArray(yamlData) ? [] : Object.keys(yamlData || {}), - itemCount: Array.isArray(yamlData) ? yamlData.length : undefined, - depth: getYamlDepth(yamlData), - } +/** + * Type guard for {@link YamlComplexityError}. Callers use this to fail closed on + * a complexity-limit rejection instead of falling back to a generic parse. + */ +export function isYamlComplexityError(error: unknown): error is YamlComplexityError { + return error instanceof YamlComplexityError +} - return { - content: jsonContent, - metadata, +/** + * Exact serialized length (in UTF-16 code units — the unit V8 allocates for the + * resulting string) that `JSON.stringify` produces for a string, accounting for + * the escape expansion of quotes, backslashes, control characters, and lone + * surrogates. Computed precisely rather than with a flat multiplier so plain + * text is charged its true size (no false rejection of large legitimate + * documents) while escape-heavy strings are charged their real, larger cost + * (no cap bypass). + */ +function serializedStringLength(value: string): number { + let length = 2 // surrounding quotes + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i) + if (code === 0x22 /* " */ || code === 0x5c /* \ */) { + length += 2 + } else if (code < 0x20) { + // \b \t \n \f \r use two-char escapes; other control chars use \uXXXX (six) + length += + code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d ? 2 : 6 + } else if (code >= 0xd800 && code <= 0xdfff) { + // Well-formed JSON.stringify emits a valid high+low surrogate pair as-is + // (two code units) but escapes a lone surrogate to \uXXXX (six). + const next = i + 1 < value.length ? value.charCodeAt(i + 1) : 0 + if (code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) { + length += 2 + i++ + } else { + length += 6 + } + } else { + length += 1 } - } catch (error) { - throw new Error(`Invalid YAML: ${getErrorMessage(error, 'Unknown error')}`) } + return length } /** - * Parse YAML from buffer + * Estimate the pretty-printed (`JSON.stringify(value, null, 2)`) size a single + * value node contributes, including the indentation/newline overhead that + * dominates deeply nested alias bombs and the exact escape expansion of strings. */ -export async function parseYAMLBuffer(buffer: Buffer): Promise { - const content = buffer.toString('utf-8') +function estimateNodeBytes(value: unknown, depth: number): number { + const indentOverhead = depth * 2 + 4 + if (typeof value === 'string') return indentOverhead + serializedStringLength(value) + return indentOverhead + 16 +} - try { - const yamlData = yaml.load(content) - const jsonContent = JSON.stringify(yamlData, null, 2) - - const metadata = { - type: 'yaml', - isArray: Array.isArray(yamlData), - keys: Array.isArray(yamlData) ? [] : Object.keys(yamlData || {}), - itemCount: Array.isArray(yamlData) ? yamlData.length : undefined, - depth: getYamlDepth(yamlData), +/** + * Estimate the serialized size of an object key (`"key": `). Keys are re-emitted + * on every alias expansion of their parent object, so an aliased object with a + * long key amplifies just like an aliased value — this must be charged or the + * size cap is trivially bypassed. + */ +function estimateKeyBytes(key: string): number { + return serializedStringLength(key) + 2 // ": " +} + +/** + * Iteratively walk the parsed YAML value with strict node-count, output-size, + * and depth limits, returning the document depth. Repeated (aliased) references + * are intentionally counted each time they are reached, mirroring the way + * `JSON.stringify` expands them — this is what makes the alias-expansion bomb + * detectable before serialization. + * + * Each node is charged against the caps as it is *enqueued*, before its own + * children are pushed, and only container nodes are pushed onto the traversal + * stack. A pathologically wide fan-out (e.g. an array of millions of aliases) + * therefore trips a cap during the enqueue loop instead of first materializing + * millions of stack entries and exhausting memory inside the guard itself. + * + * @throws {YamlComplexityError} when any limit is exceeded + */ +export function assertYamlWithinLimits(root: unknown): number { + let visited = 0 + let estimatedBytes = 0 + let maxDepth = 0 + + const charge = (bytes: number): void => { + if (++visited > MAX_YAML_EXPANDED_NODES) { + throw new YamlComplexityError( + `YAML document exceeds the maximum of ${MAX_YAML_EXPANDED_NODES} expanded nodes (possible alias-expansion bomb)` + ) } + estimatedBytes += bytes + if (estimatedBytes > MAX_YAML_SERIALIZED_BYTES) { + throw new YamlComplexityError( + `YAML document expands beyond the maximum serialized size of ${MAX_YAML_SERIALIZED_BYTES} bytes (possible alias-expansion bomb)` + ) + } + } + + const isContainer = (value: unknown): value is object => + value !== null && typeof value === 'object' + + charge(estimateNodeBytes(root, 0)) + const stack: Array<{ value: object; depth: number }> = [] + if (isContainer(root)) stack.push({ value: root, depth: 0 }) + + while (stack.length > 0) { + const { value, depth } = stack.pop()! + const childDepth = depth + 1 - return { - content: jsonContent, - metadata, + if (childDepth > maxDepth) maxDepth = childDepth + if (childDepth > MAX_YAML_DEPTH) { + throw new YamlComplexityError( + `YAML document exceeds the maximum nesting depth of ${MAX_YAML_DEPTH}` + ) } + + if (Array.isArray(value)) { + for (const child of value) { + charge(estimateNodeBytes(child, childDepth)) + if (isContainer(child)) stack.push({ value: child, depth: childDepth }) + } + } else { + for (const [key, child] of Object.entries(value as Record)) { + charge(estimateKeyBytes(key) + estimateNodeBytes(child, childDepth)) + if (isContainer(child)) stack.push({ value: child, depth: childDepth }) + } + } + } + + return maxDepth +} + +/** + * Parse a YAML value into the shared `FileParseResult` shape after validating + * that its expanded form stays within safe complexity limits. + */ +function buildYamlResult(yamlData: unknown): FileParseResult { + const depth = assertYamlWithinLimits(yamlData) + const jsonContent = JSON.stringify(yamlData, null, 2) + + const metadata = { + type: 'yaml', + isArray: Array.isArray(yamlData), + keys: Array.isArray(yamlData) ? [] : Object.keys((yamlData as Record) || {}), + itemCount: Array.isArray(yamlData) ? yamlData.length : undefined, + depth, + } + + return { + content: jsonContent, + metadata, + } +} + +/** + * Parse YAML files + */ +export async function parseYAML(filePath: string): Promise { + const fs = await import('fs/promises') + const content = await fs.readFile(filePath, 'utf-8') + + try { + const yamlData = yaml.load(content) + return buildYamlResult(yamlData) } catch (error) { + if (error instanceof YamlComplexityError) throw error throw new Error(`Invalid YAML: ${getErrorMessage(error, 'Unknown error')}`) } } /** - * Calculate the depth of a YAML/JSON object + * Parse YAML from buffer */ -function getYamlDepth(obj: any): number { - if (obj === null || typeof obj !== 'object') return 0 +export async function parseYAMLBuffer(buffer: Buffer): Promise { + const content = buffer.toString('utf-8') - if (Array.isArray(obj)) { - return obj.length > 0 ? 1 + Math.max(...obj.map(getYamlDepth)) : 1 + try { + const yamlData = yaml.load(content) + return buildYamlResult(yamlData) + } catch (error) { + if (error instanceof YamlComplexityError) throw error + throw new Error(`Invalid YAML: ${getErrorMessage(error, 'Unknown error')}`) } - - const depths = Object.values(obj).map(getYamlDepth) - return depths.length > 0 ? 1 + Math.max(...depths) : 1 }