CVE-2026-33532
Description
yaml is a YAML parser and serialiser for JavaScript. Parsing a YAML document with a version of yaml on the 1.x branch prior to 1.10.3 or on the 2.x branch prior to 2.8.3 may throw a RangeError due to a stack overflow. The node resolution/composition phase uses recursive function calls without a depth bound. An attacker who can supply YAML for parsing can trigger a RangeError: Maximum call stack size exceeded with a small payload (~2–10 KB). The RangeError is not a YAMLParseError, so applications that only catch YAML-specific errors will encounter an unexpected exception type. Depending on the host application's exception handling, this can fail requests or terminate the Node.js process. Flow sequences allow deep nesting with minimal bytes (2 bytes per level: one [ and one ]). On the default Node.js stack, approximately 1,000–5,000 levels of nesting (2–10 KB input) exhaust the call stack. The exact threshold is environment-dependent (Node.js version, stack size, call stack depth at invocation). Note: the library's Parser (CST phase) uses a stack-based iterative approach and is not affected. Only the compose/resolve phase uses actual call-stack recursion. All three public parsing APIs are affected: YAML.parse(), YAML.parseDocument(), and YAML.parseAllDocuments(). Versions 1.10.3 and 2.8.3 contain a patch.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
yamlnpm | >= 2.0.0, < 2.8.3 | 2.8.3 |
yamlnpm | >= 1.0.0, < 1.10.3 | 1.10.3 |
Affected products
1Patches
11e84ebbea7ecfix: Catch stack overflow during node composition
4 files changed · +97 −85
docs/08_errors.md+1 −0 modified@@ -43,6 +43,7 @@ To identify errors for special handling, you should primarily use `code` to diff | `MULTIPLE_DOCS` | A YAML stream may include multiple documents. If yours does, you'll need to use `parseAllDocuments()` to work with it. | | `MULTIPLE_TAGS` | A node is only allowed to have one tag. | | `NON_STRING_KEY` | With the `stringKeys` option, all mapping keys must be strings | +| `RESOURCE_EXHAUSTION` | The input document has excessive nesting, leading to a stack overflow during parsing. | | `TAB_AS_INDENT` | Only spaces are allowed as indentation. | | `TAG_RESOLVE_FAILED` | Something went wrong when resolving a node's tag with the current schema. | | `UNEXPECTED_TOKEN` | A token was encountered in a place where it wasn't expected. |
src/compose/compose-node.ts+9 −10 modified@@ -61,26 +61,25 @@ export function composeNode( case 'block-map': case 'block-seq': case 'flow-collection': - node = composeCollection(CN, ctx, token, props, onError) - if (anchor) node.anchor = anchor.source.substring(1) + try { + node = composeCollection(CN, ctx, token, props, onError) + if (anchor) node.anchor = anchor.source.substring(1) + } catch (error) { + // Almost certainly here due to a stack overflow + const message = error instanceof Error ? error.message : String(error) + onError(token, 'RESOURCE_EXHAUSTION', message) + } break default: { const message = token.type === 'error' ? token.message : `Unsupported token (type: ${token.type})` onError(token, 'UNEXPECTED_TOKEN', message) - node = composeEmptyNode( - ctx, - token.offset, - undefined, - null, - props, - onError - ) isSrcToken = false } } + node ??= composeEmptyNode(ctx, token.offset, undefined, null, props, onError) if (anchor && node.anchor === '') onError(anchor, 'BAD_ALIAS', 'Anchor cannot be an empty string') if (
src/errors.ts+1 −0 modified@@ -19,6 +19,7 @@ export type ErrorCode = | 'MULTIPLE_DOCS' | 'MULTIPLE_TAGS' | 'NON_STRING_KEY' + | 'RESOURCE_EXHAUSTION' | 'TAB_AS_INDENT' | 'TAG_RESOLVE_FAILED' | 'UNEXPECTED_TOKEN'
tests/doc/parse.ts+86 −75 modified@@ -453,98 +453,109 @@ describe('odd indentations', () => { }) }) -describe('Excessive entity expansion attacks', () => { - const root = resolve(__dirname, '../artifacts/pr104') - const src1 = readFileSync(resolve(root, 'case1.yml'), 'utf8') - const src2 = readFileSync(resolve(root, 'case2.yml'), 'utf8') - const srcB = readFileSync(resolve(root, 'billion-laughs.yml'), 'utf8') - const srcQ = readFileSync(resolve(root, 'quadratic.yml'), 'utf8') - - let origEmitWarning: typeof process.emitWarning - beforeAll(() => { - origEmitWarning = process.emitWarning - }) - afterAll(() => { - process.emitWarning = origEmitWarning - }) - - describe('Limit count by default', () => { - for (const [name, src] of [ - ['js-yaml case 1', src1], - ['js-yaml case 2', src2], - ['billion laughs', srcB], - ['quadratic expansion', srcQ] - ]) { - test(name, () => { - process.emitWarning = jest.fn() - expect(() => YAML.parse(src)).toThrow(/Excessive alias count/) - }) +describe('Resource exhaustion attacks', () => { + test('Excessive recursion', () => { + const depth = 5000 + const src = '['.repeat(depth) + '1' + ']'.repeat(depth) + const doc = YAML.parseDocument(src) + for (const error of doc.errors) { + expect(error).toMatchObject({ code: 'RESOURCE_EXHAUSTION' }) } }) - describe('Work sensibly even with disabled limits', () => { - test('js-yaml case 1', () => { - process.emitWarning = jest.fn() - const obj = YAML.parse(src1, { maxAliasCount: -1 }) - expect(obj).toMatchObject({}) - const key = Object.keys(obj)[0] - expect(key.length).toBeGreaterThan(2000) - expect(key.length).toBeLessThan(8000) - expect(process.emitWarning).toHaveBeenCalled() - }) + describe('Excessive entity expansion attacks', () => { + const root = resolve(__dirname, '../artifacts/pr104') + const src1 = readFileSync(resolve(root, 'case1.yml'), 'utf8') + const src2 = readFileSync(resolve(root, 'case2.yml'), 'utf8') + const srcB = readFileSync(resolve(root, 'billion-laughs.yml'), 'utf8') + const srcQ = readFileSync(resolve(root, 'quadratic.yml'), 'utf8') - test('js-yaml case 2', () => { - const arr = YAML.parse(src2, { maxAliasCount: -1 }) - expect(arr).toHaveLength(2) - const key = Object.keys(arr[1])[0] - expect(key).toBe('*id057') + let origEmitWarning: typeof process.emitWarning + beforeAll(() => { + origEmitWarning = process.emitWarning }) - - test('billion laughs', () => { - const obj = YAML.parse(srcB, { maxAliasCount: -1 }) - expect(Object.keys(obj)).toHaveLength(9) + afterAll(() => { + process.emitWarning = origEmitWarning }) - test('quadratic expansion', () => { - const obj = YAML.parse(srcQ, { maxAliasCount: -1 }) - expect(Object.keys(obj)).toHaveLength(11) + describe('Limit count by default', () => { + for (const [name, src] of [ + ['js-yaml case 1', src1], + ['js-yaml case 2', src2], + ['billion laughs', srcB], + ['quadratic expansion', srcQ] + ]) { + test(name, () => { + process.emitWarning = jest.fn() + expect(() => YAML.parse(src)).toThrow(/Excessive alias count/) + }) + } }) - }) - describe('maxAliasCount limits', () => { - const rows = [ - 'a: &a [lol, lol, lol, lol, lol, lol, lol, lol, lol]', - 'b: &b [*a, *a, *a, *a, *a, *a, *a, *a, *a]', - 'c: &c [*b, *b, *b, *b]', - 'd: &d [*c, *c]', - 'e: [*d]' - ] + describe('Work sensibly even with disabled limits', () => { + test('js-yaml case 1', () => { + process.emitWarning = jest.fn() + const obj = YAML.parse(src1, { maxAliasCount: -1 }) + expect(obj).toMatchObject({}) + const key = Object.keys(obj)[0] + expect(key.length).toBeGreaterThan(2000) + expect(key.length).toBeLessThan(8000) + expect(process.emitWarning).toHaveBeenCalled() + }) - test(`depth 0: maxAliasCount 1 passes`, () => { - expect(() => YAML.parse(rows[0], { maxAliasCount: 1 })).not.toThrow() - }) + test('js-yaml case 2', () => { + const arr = YAML.parse(src2, { maxAliasCount: -1 }) + expect(arr).toHaveLength(2) + const key = Object.keys(arr[1])[0] + expect(key).toBe('*id057') + }) + + test('billion laughs', () => { + const obj = YAML.parse(srcB, { maxAliasCount: -1 }) + expect(Object.keys(obj)).toHaveLength(9) + }) - test(`depth 1: maxAliasCount 1 fails on first alias`, () => { - const src = `${rows[0]}\nb: *a` - expect(() => YAML.parse(src, { maxAliasCount: 1 })).toThrow() + test('quadratic expansion', () => { + const obj = YAML.parse(srcQ, { maxAliasCount: -1 }) + expect(Object.keys(obj)).toHaveLength(11) + }) }) - const limits = [10, 50, 150, 300] - for (let i = 0; i < 4; ++i) { - const src = rows.slice(0, i + 2).join('\n') + describe('maxAliasCount limits', () => { + const rows = [ + 'a: &a [lol, lol, lol, lol, lol, lol, lol, lol, lol]', + 'b: &b [*a, *a, *a, *a, *a, *a, *a, *a, *a]', + 'c: &c [*b, *b, *b, *b]', + 'd: &d [*c, *c]', + 'e: [*d]' + ] - test(`depth ${i + 1}: maxAliasCount ${limits[i] - 1} fails`, () => { - expect(() => - YAML.parse(src, { maxAliasCount: limits[i] - 1 }) - ).toThrow() + test(`depth 0: maxAliasCount 1 passes`, () => { + expect(() => YAML.parse(rows[0], { maxAliasCount: 1 })).not.toThrow() }) - test(`depth ${i + 1}: maxAliasCount ${limits[i]} passes`, () => { - expect(() => - YAML.parse(src, { maxAliasCount: limits[i] }) - ).not.toThrow() + test(`depth 1: maxAliasCount 1 fails on first alias`, () => { + const src = `${rows[0]}\nb: *a` + expect(() => YAML.parse(src, { maxAliasCount: 1 })).toThrow() }) - } + + const limits = [10, 50, 150, 300] + for (let i = 0; i < 4; ++i) { + const src = rows.slice(0, i + 2).join('\n') + + test(`depth ${i + 1}: maxAliasCount ${limits[i] - 1} fails`, () => { + expect(() => + YAML.parse(src, { maxAliasCount: limits[i] - 1 }) + ).toThrow() + }) + + test(`depth ${i + 1}: maxAliasCount ${limits[i]} passes`, () => { + expect(() => + YAML.parse(src, { maxAliasCount: limits[i] }) + ).not.toThrow() + }) + } + }) }) })
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/eemeli/yaml/commit/1e84ebbea7ec35011a4c61bbb820a529ee4f359bnvdPatchWEB
- github.com/eemeli/yaml/security/advisories/GHSA-48c2-rrv3-qjmpnvdExploitVendor AdvisoryWEB
- github.com/advisories/GHSA-48c2-rrv3-qjmpghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33532ghsaADVISORY
- github.com/eemeli/yaml/releases/tag/v1.10.3nvdProductWEB
- github.com/eemeli/yaml/releases/tag/v2.8.3nvdRelease NotesWEB
News mentions
0No linked articles in our index yet.