Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).


## [5.2.0] - 2026-06-26
### Added
- Added `maxTotalMergeKeys` (10000) loader option to limit the total number of
keys processed by YAML merge (`<<`) across one `load()` / `loadAll()` call.
- Added `maxAliases` (-1) loader option to limit the number of YAML aliases per
document.

### Removed
- `maxMergeSeqLength` replaced with `maxTotalMergeKeys` for limiting YAML merge
processing.

### Fixed
- Round-trip of integers with exponential form (>= `1e21`)

## [5.1.0] - 2026-06-23
### Added
- Collection tags can finalize an incrementally populated carrier into a
Expand Down Expand Up @@ -75,8 +89,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added `docs/safety.md` with notes about processing untrusted YAML.
- Added `maxDepth` (100) loader option. Not a problem, but gives a better
exception instead of RangeError on stack overflow.
- Added `maxMergeSeqLength` (20) loader option. Not a problem after `merge` fix,
but an additional restriction for safety.
- Added a loader option limiting merge sequence length. Not a problem after
`merge` fix, but an additional restriction for safety.
- Added sourcemaps to `dist/` builds.

### Changed
Expand Down Expand Up @@ -645,6 +659,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- First public release


[5.2.0]: https://github.com/nodeca/js-yaml/compare/5.1.0...5.2.0
[5.1.0]: https://github.com/nodeca/js-yaml/compare/5.0.0...5.1.0
[5.0.0]: https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0
[4.2.0]: https://github.com/nodeca/js-yaml/compare/4.1.1...4.2.0
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,11 @@ options:
error.
- `maxDepth` _(default: 100)_ - limits the nesting depth for collections (does
not take aliases into account).
- `maxMergeSeqLength` _(default: 20)_ - limits the number of items in merge
(`<<`) sequences.
- `maxTotalMergeKeys` _(default: 10000)_ - limits the total number of keys
processed by merge (`<<`) across one `load()` / `loadAll()` call. Set to `-1`
to disable.
- `maxAliases` _(default: -1)_ - limits the number of alias nodes (`*ref`) per
document. Set to `0` to reject all aliases, or to `-1` for no limit.

> [!NOTE]
>
Expand Down
577 changes: 315 additions & 262 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "js-yaml",
"version": "5.1.0",
"version": "5.2.0",
"description": "YAML 1.2 parser and serializer",
"keywords": [
"yaml",
Expand Down
32 changes: 20 additions & 12 deletions src/parser/constructor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,17 @@ interface ConstructorOptions {
filename?: string
schema?: Schema
json?: boolean
maxMergeSeqLength?: number
maxTotalMergeKeys?: number
maxAliases?: number
}

// `source` is input data, not config — so it has no default here.
const DEFAULT_CONSTRUCTOR_OPTIONS: Required<Omit<ConstructorOptions, 'source'>> = {
filename: '',
schema: CORE_SCHEMA,
json: false,
maxMergeSeqLength: 20
maxTotalMergeKeys: 10000,
maxAliases: -1
}

interface ConstructorState extends Required<ConstructorOptions> {
Expand All @@ -99,6 +101,8 @@ interface ConstructorState extends Required<ConstructorOptions> {
frames: Frame[]
anchors: Map<string, Anchor>
tagHandlers: TagHandlers
totalMergeKeys: number
aliasCount: number
}

function eventPosition (event: Event) {
Expand Down Expand Up @@ -252,6 +256,10 @@ function isMappingTag (tag: AnyTag): tag is MappingTagDefinition<any, any> {
// precedence: an already-present key (explicit or from an earlier source) wins.
function mergeKeys (state: ConstructorState, frame: MappingFrame, source: unknown, sourceTag: MappingTagDefinition<any, any>) {
for (const sourceKey of sourceTag.keys(source)) {
if (state.maxTotalMergeKeys !== -1 && ++state.totalMergeKeys > state.maxTotalMergeKeys) {
throwError(state, `merge keys exceeded maxTotalMergeKeys (${state.maxTotalMergeKeys})`)
}

if (frame.tag.has(frame.value, sourceKey)) continue

const err = frame.tag.addPair(frame.value, sourceKey, sourceTag.get(source, sourceKey))
Expand All @@ -270,11 +278,7 @@ function mergeSource (state: ConstructorState, frame: MappingFrame, source: unkn
if (isMappingTag(sourceTag)) {
mergeKeys(state, frame, source, sourceTag)
} else if (sourceTag.nodeKind === 'sequence' && Array.isArray(source)) {
const seen = new Set<unknown>()
for (const element of source) {
// Dedup identical sources (`<<: [*a, *a]`); the first one wins anyway.
if (seen.has(element)) continue
seen.add(element)
mergeKeys(state, frame, element, frame.tag)
}
} else {
Expand Down Expand Up @@ -308,14 +312,11 @@ function addValue (state: ConstructorState, value: unknown, tag: AnyTag) {
frame.hasValue = true
} else if (frame.kind === 'sequence') {
if (frame.merge) {
// Element of a `<<: [...]` list: validate it is a mapping and cap the
// length, then collect it like any other item for the target to fold in.
// Element of a `<<: [...]` list: validate it is a mapping, then collect
// it like any other item for the target to fold in.
if (!isMappingTag(tag)) {
throwError(state, 'cannot merge mappings; the provided source object is unacceptable')
}
if (frame.index >= state.maxMergeSeqLength) {
throwError(state, `merge sequence length exceeded maxMergeSeqLength (${state.maxMergeSeqLength})`)
}
}
const err = frame.tag.addItem(frame.value, value, frame.index++)
if (err) throwError(state, err)
Expand Down Expand Up @@ -361,7 +362,9 @@ function constructFromEvents (events: Event[], options: ConstructorOptions): unk
position: 0,
frames: [],
anchors: new Map(),
tagHandlers: Object.create(null)
tagHandlers: Object.create(null),
totalMergeKeys: 0,
aliasCount: 0
}

while (state.eventIndex < state.events.length) {
Expand All @@ -371,6 +374,7 @@ function constructFromEvents (events: Event[], options: ConstructorOptions): unk
switch (event.type) {
case EVENT_DOCUMENT:
state.anchors = new Map()
state.aliasCount = 0
state.tagHandlers = Object.create(null)
for (const directive of event.directives) {
if (directive.kind === 'tag') state.tagHandlers[directive.handle] = directive.prefix
Expand Down Expand Up @@ -436,6 +440,10 @@ function constructFromEvents (events: Event[], options: ConstructorOptions): unk
}

case EVENT_ALIAS: {
if (state.maxAliases !== -1 && ++state.aliasCount > state.maxAliases) {
throwError(state, `aliases exceeded maxAliases (${state.maxAliases})`)
}

const name = state.source.slice(event.anchorStart, event.anchorEnd)
const anchor = state.anchors.get(name)
if (!anchor) {
Expand Down
58 changes: 18 additions & 40 deletions test/core/pathological.test.mjs
Original file line number Diff line number Diff line change
@@ -1,39 +1,31 @@
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { load, YAMLException, YAML11_SCHEMA } from 'js-yaml'
import workerpool from 'workerpool'

// Resolved in the main thread; passed into the worker since the worker's
// eval scope has no `require`. `import()` is syntax, so it survives eval.
const yamlUrl = import.meta.resolve('js-yaml')

async function loadYamlInWorker (doc, url, options) {
const mod = await import(url)
mod.load(doc, options)
}

function assertYamlException (fn, pattern) {
try {
fn()
} catch (error) {
assert(
error instanceof YAMLException,
`expected YAMLException, got ${error.name}`
)
if (pattern) assert.match(error.message, pattern)
if (!(error instanceof YAMLException)) {
throw new Error(`expected YAMLException, got ${error.name}`)
}
if (pattern && !pattern.test(error.message)) {
throw new Error(`expected ${error.message} to match ${pattern}`)
}
return
}

assert.fail('expected YAMLException')
throw new Error('expected YAMLException')
}

function createRepeatedMergeAliasPattern (repetitions, keys) {
const src = Array.from({ length: keys }, (_, i) => `k${i}: 0`).join(', ')
function createMergeChain (count) {
const lines = ['a0: &a0 { k0: 0 }']

return `
a: &a {${src}}
b: { <<: [ ${'*a, '.repeat(repetitions - 1)}*a ] }
`
for (let i = 1; i < count; i++) {
lines.push(`a${i}: &a${i} { <<: *a${i - 1}, k${i}: ${i} }`)
}

lines.push(`b: *a${count - 1}`)
return `${lines.join('\n')}\n`
}

describe('Pathological tests', () => {
Expand All @@ -50,24 +42,10 @@ describe('Pathological tests', () => {
})

describe('Merge aliases', () => {
it('loads repeated merge aliases with many keys', async () => {
const doc = createRepeatedMergeAliasPattern(100000, 100000)
const pool = workerpool.pool()
try {
await pool.exec(loadYamlInWorker, [doc, yamlUrl, { maxMergeSeqLength: 1000000 }])
.timeout(10 * 1000)
} finally {
await pool.terminate()
}
})

it('throws YAMLException on long merge sequence (over maxMergeSeqLength)', () => {
it('throws YAMLException when merge chain exceeds maxTotalMergeKeys', () => {
assertYamlException(() => {
load(`
a: &a { k: 0 }
b: { <<: [ ${'*a, '.repeat(20)}*a ] }
`, { schema: YAML11_SCHEMA })
}, /merge sequence length exceeded maxMergeSeqLength/)
load(createMergeChain(100000), { schema: YAML11_SCHEMA })
}, /merge keys exceeded maxTotalMergeKeys/)
})
})
})
17 changes: 0 additions & 17 deletions test/core/tags/merge.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,23 +58,6 @@ foo: bar
)
})

it('deduplicates repeated merge sequence sources', () => {
// This test is coverage only, to toggle optional deduplication branch
const src = `
base: &b { x: 1 }
merged: { <<: [*b, *b], y: 2 }
`
const expected = {
base: { x: 1 },
merged: { x: 1, y: 2 }
}

assert.deepStrictEqual(
load(src, { schema: CORE_SCHEMA.withTags(mergeTag) }),
expected
)
})

it('throws when the target mapping tag rejects a merged pair', () => {
const src = `
--- !!set
Expand Down
60 changes: 57 additions & 3 deletions test/core/units/load-options.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@ import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { load, loadAll, FAILSAFE_SCHEMA, YAML11_SCHEMA, YAMLException } from 'js-yaml'

function createMergeChain (count) {
const lines = ['a0: &a0 { k0: 0 }']

for (let i = 1; i < count; i++) {
lines.push(`a${i}: &a${i} { <<: *a${i - 1}, k${i}: ${i} }`)
}

lines.push(`b: *a${count - 1}`)
return `${lines.join('\n')}\n`
}

describe('load options', () => {
it('filename — included in error messages', () => {
assert.throws(() => load('@', { filename: 'my.yml' }), /my\.yml/)
Expand All @@ -25,13 +36,56 @@ describe('load options', () => {
assert.throws(() => load(nested, { maxDepth: 5 }), /maxDepth/)
})

it('maxMergeSeqLength — caps merge sequence length', () => {
it('maxTotalMergeKeys — caps total merge keys', () => {
const merge = (n) =>
Array.from({ length: n }, (_, i) => `- &x${i} {a${i}: ${i}}`).join('\n') +
'\n- <<: [' + Array.from({ length: n }, (_, i) => `*x${i}`).join(', ') + ']\n'

assert.doesNotThrow(() => load(merge(3), { schema: YAML11_SCHEMA, maxMergeSeqLength: 5 }))
assert.throws(() => load(merge(3), { schema: YAML11_SCHEMA, maxMergeSeqLength: 2 }), /maxMergeSeqLength/)
assert.doesNotThrow(() => load(merge(3), { schema: YAML11_SCHEMA, maxTotalMergeKeys: 5 }))
assert.throws(() => load(merge(3), { schema: YAML11_SCHEMA, maxTotalMergeKeys: 2 }), /maxTotalMergeKeys/)
assert.doesNotThrow(() => load(merge(3), { schema: YAML11_SCHEMA, maxTotalMergeKeys: -1 }))

const result = load(createMergeChain(150), { schema: YAML11_SCHEMA, maxTotalMergeKeys: -1 })
assert.equal(Object.keys(result.b).length, 150)
})

it('loadAll — maxTotalMergeKeys is shared across all documents', () => {
const src = `
---
a: &a { k1: 1, k2: 2 }
b: { <<: *a }
---
a: &a { k1: 1, k2: 2 }
b: { <<: *a }
`
assert.doesNotThrow(() => loadAll(src, { schema: YAML11_SCHEMA, maxTotalMergeKeys: 4 }))
assert.throws(() => loadAll(src, { schema: YAML11_SCHEMA, maxTotalMergeKeys: 3 }), /maxTotalMergeKeys/)
})

it('maxAliases — caps alias count', () => {
const src = `
base: &base { a: 1 }
one: *base
two: *base
`
assert.deepEqual(load(src).one, { a: 1 })
assert.doesNotThrow(() => load(src, { maxAliases: 2 }))
assert.throws(() => load(src, { maxAliases: 1 }), /maxAliases/)
assert.throws(() => load(src, { maxAliases: 0 }), /maxAliases/)
assert.doesNotThrow(() => load(src, { maxAliases: -1 }))
})

it('loadAll — maxAliases is applied per document', () => {
const src = `
---
a: &a 1
b: *a
---
a: &a 1
b: *a
`
assert.doesNotThrow(() => loadAll(src, { maxAliases: 1 }))
assert.throws(() => loadAll(src, { maxAliases: 0 }), /maxAliases/)
})

it('loadAll — options reach every argument form', () => {
Expand Down
Loading