From 6ee6a9b6e990a946dd01ad7070de7bd80e42d34d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 18 Jul 2026 11:36:25 -0700 Subject: [PATCH 1/3] fix(security): bound YAML alias expansion in file-parser to prevent DoS The YAML parser called yaml.load() then JSON.stringify() on the result. YAML aliases (*anchor) resolve to shared references, so yaml.load cheaply builds a compact DAG, but JSON.stringify expands that DAG into a full tree, duplicating every shared node. A crafted sub-1KB .yaml/.yml document could therefore expand to hundreds of MB / GB during serialization, pinning CPU and OOM-killing the shared parse/ingestion worker (CWE-776). Add a bounded, iterative traversal that runs before JSON.stringify and aborts once expanded node count, estimated serialized size, or nesting depth exceeds safe caps. This detects the amplification against the compact DAG before any allocation, and also terminates cyclic anchor structures. Replaces the unbounded recursive getYamlDepth (which spread large arrays into Math.max(...array), risking a stack overflow) with the same bounded walk. --- apps/sim/lib/file-parsers/yaml-parser.test.ts | 78 ++++++++ apps/sim/lib/file-parsers/yaml-parser.ts | 172 +++++++++++++----- 2 files changed, 204 insertions(+), 46 deletions(-) create mode 100644 apps/sim/lib/file-parsers/yaml-parser.test.ts 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..2dbf3cb4876 --- /dev/null +++ b/apps/sim/lib/file-parsers/yaml-parser.test.ts @@ -0,0 +1,78 @@ +/** + * @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('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) + }) +}) diff --git a/apps/sim/lib/file-parsers/yaml-parser.ts b/apps/sim/lib/file-parsers/yaml-parser.ts index 7af7743a296..a30aaffad50 100644 --- a/apps/sim/lib/file-parsers/yaml-parser.ts +++ b/apps/sim/lib/file-parsers/yaml-parser.ts @@ -3,74 +3,154 @@ 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 - // Convert to JSON for consistent processing - const jsonContent = JSON.stringify(yamlData, null, 2) +/** + * 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 + +/** + * 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), +/** + * Estimate the pretty-printed (`JSON.stringify(value, null, 2)`) byte cost a + * single node contributes, including the indentation/newline overhead that + * dominates deeply nested alias bombs. + */ +function estimateNodeBytes(value: unknown, depth: number): number { + const indentOverhead = depth * 2 + 4 + if (typeof value === 'string') return indentOverhead + value.length + 2 + return indentOverhead + 16 +} + +/** + * 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. + * + * @throws {YamlComplexityError} when any limit is exceeded + */ +export function assertYamlWithinLimits(root: unknown): number { + let visited = 0 + let estimatedBytes = 0 + let maxDepth = 0 + + const stack: Array<{ value: unknown; depth: number }> = [{ value: root, depth: 0 }] + + while (stack.length > 0) { + const { value, depth } = stack.pop()! + + 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)` + ) } - return { - content: jsonContent, - metadata, + estimatedBytes += estimateNodeBytes(value, depth) + 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)` + ) + } + + if (value === null || typeof value !== 'object') continue + + if (depth + 1 > maxDepth) maxDepth = depth + 1 + if (depth + 1 > MAX_YAML_DEPTH) { + throw new YamlComplexityError( + `YAML document exceeds the maximum nesting depth of ${MAX_YAML_DEPTH}` + ) + } + + const children = Array.isArray(value) ? value : Object.values(value as Record) + for (const child of children) { + stack.push({ value: child, depth: depth + 1 }) } - } catch (error) { - throw new Error(`Invalid YAML: ${getErrorMessage(error, 'Unknown error')}`) } + + return maxDepth } /** - * Parse YAML from buffer + * Parse a YAML value into the shared `FileParseResult` shape after validating + * that its expanded form stays within safe complexity limits. */ -export async function parseYAMLBuffer(buffer: Buffer): Promise { - const content = buffer.toString('utf-8') +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) - 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), - } - - return { - content: jsonContent, - metadata, - } + 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 } From 7846504c12bf70a6108e3f4e87750c13afa9a4e7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 18 Jul 2026 11:56:34 -0700 Subject: [PATCH 2/3] harden YAML guard: charge keys + escaping, bound stack, fail closed Addresses review findings on the alias-expansion guard: - Charge object keys, not just values. JSON.stringify re-emits every key on each alias expansion, so an aliased object with a long key amplified without being counted. Keys are now charged against the size cap. - Compute the exact JSON-escaped string length (quotes, backslashes, control chars) instead of a flat multiplier. Plain text is charged its true 1:1 size (no false rejection of large legitimate documents) while escape-heavy strings are charged their real, larger cost (no cap bypass). - Count each node as it is enqueued and only push container nodes onto the traversal stack. A pathologically wide fan-out now trips a cap during the enqueue loop instead of first materializing millions of stack entries and exhausting memory inside the guard itself. - Fail closed in POST /api/files/parse: a YamlComplexityError rejection is now re-thrown (mirroring isPayloadSizeLimitError) rather than silently falling back to storing the crafted document as raw text. --- apps/sim/app/api/files/parse/route.ts | 4 + apps/sim/lib/file-parsers/yaml-parser.test.ts | 37 ++++++++ apps/sim/lib/file-parsers/yaml-parser.ts | 95 +++++++++++++++---- 3 files changed, 119 insertions(+), 17 deletions(-) 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 index 2dbf3cb4876..de9c8246e08 100644 --- a/apps/sim/lib/file-parsers/yaml-parser.test.ts +++ b/apps/sim/lib/file-parsers/yaml-parser.test.ts @@ -50,6 +50,22 @@ describe('parseYAMLBuffer', () => { 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/ @@ -75,4 +91,25 @@ describe('assertYamlWithinLimits', () => { 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) + }) }) diff --git a/apps/sim/lib/file-parsers/yaml-parser.ts b/apps/sim/lib/file-parsers/yaml-parser.ts index a30aaffad50..278e12debac 100644 --- a/apps/sim/lib/file-parsers/yaml-parser.ts +++ b/apps/sim/lib/file-parsers/yaml-parser.ts @@ -42,16 +42,59 @@ export class YamlComplexityError extends Error { } /** - * Estimate the pretty-printed (`JSON.stringify(value, null, 2)`) byte cost a - * single node contributes, including the indentation/newline overhead that - * dominates deeply nested alias bombs. + * 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 +} + +/** + * 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, and control characters. 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 { + length += 1 + } + } + return length +} + +/** + * 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. */ function estimateNodeBytes(value: unknown, depth: number): number { const indentOverhead = depth * 2 + 4 - if (typeof value === 'string') return indentOverhead + value.length + 2 + if (typeof value === 'string') return indentOverhead + serializedStringLength(value) return indentOverhead + 16 } +/** + * 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 @@ -59,6 +102,12 @@ function estimateNodeBytes(value: unknown, depth: number): number { * `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 { @@ -66,36 +115,48 @@ export function assertYamlWithinLimits(root: unknown): number { let estimatedBytes = 0 let maxDepth = 0 - const stack: Array<{ value: unknown; depth: number }> = [{ value: root, depth: 0 }] - - while (stack.length > 0) { - const { value, depth } = stack.pop()! - + 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 += estimateNodeBytes(value, depth) + 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' - if (value === null || typeof value !== 'object') continue + 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 - if (depth + 1 > maxDepth) maxDepth = depth + 1 - if (depth + 1 > MAX_YAML_DEPTH) { + if (childDepth > maxDepth) maxDepth = childDepth + if (childDepth > MAX_YAML_DEPTH) { throw new YamlComplexityError( `YAML document exceeds the maximum nesting depth of ${MAX_YAML_DEPTH}` ) } - const children = Array.isArray(value) ? value : Object.values(value as Record) - for (const child of children) { - stack.push({ value: child, depth: depth + 1 }) + 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 }) + } } } From 5c5d8aad705281e41a1e8200a82096552861d521 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 18 Jul 2026 12:04:18 -0700 Subject: [PATCH 3/3] yaml guard: charge lone surrogates at their escaped length serializedStringLength treated surrogate code units as cost 1, but well-formed JSON.stringify escapes a lone surrogate to \uXXXX (six units). Charge lone surrogates as 6 and valid high+low pairs as-is (two units), matching JSON.stringify exactly. Verified against JSON.stringify across plain text, escapes, control chars, lone high/low surrogates, and valid pairs. --- apps/sim/lib/file-parsers/yaml-parser.test.ts | 12 ++++++++++++ apps/sim/lib/file-parsers/yaml-parser.ts | 19 +++++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/file-parsers/yaml-parser.test.ts b/apps/sim/lib/file-parsers/yaml-parser.test.ts index de9c8246e08..68464ece162 100644 --- a/apps/sim/lib/file-parsers/yaml-parser.test.ts +++ b/apps/sim/lib/file-parsers/yaml-parser.test.ts @@ -112,4 +112,16 @@ describe('assertYamlWithinLimits', () => { 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 278e12debac..a0bd297880e 100644 --- a/apps/sim/lib/file-parsers/yaml-parser.ts +++ b/apps/sim/lib/file-parsers/yaml-parser.ts @@ -52,10 +52,11 @@ export function isYamlComplexityError(error: unknown): error is YamlComplexityEr /** * 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, and control characters. 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). + * 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 @@ -67,6 +68,16 @@ function serializedStringLength(value: string): number { // \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 }