VYPR
Medium severity4.3NVD Advisory· Published Mar 26, 2026· Updated Apr 2, 2026

CVE-2026-33532

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.

PackageAffected versionsPatched versions
yamlnpm
>= 2.0.0, < 2.8.32.8.3
yamlnpm
>= 1.0.0, < 1.10.31.10.3

Affected products

1
  • cpe:2.3:a:eemeli:yaml:*:*:*:*:*:node.js:*:*
    Range: >=1.0.0,<1.10.3

Patches

1
1e84ebbea7ec

fix: Catch stack overflow during node composition

https://github.com/eemeli/yamlEemeli AroMar 21, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.