Skip to content

fix: count empty merge sources against maxTotalMergeKeys - #797

Merged
puzrin merged 1 commit into
nodeca:masterfrom
spokodev:fix-empty-merge-source-dos
Aug 26, 2026
Merged

puzrin merged 1 commit into
nodeca:masterfrom
spokodev:fix-empty-merge-source-dos

Conversation

@spokodev

Copy link
Copy Markdown
Contributor

The maxTotalMergeKeys guard added for the quadratic merge-key DoS (GHSA-g796-fgmg-93mv, fixed in 5.2.0) charges the budget once per folded key. A merge source that folds no keys — an empty mapping — costs real work to process but never increments the counter, so the guard can be bypassed:

arr: &arr [{}, {}, {}, ...]   # N empty mappings
targets:
  - <<: *arr                  # repeated K times

mergeSource iterates every element of the <<: sequence, and aliasing one N-element array of empty maps into K targets does O(K*N) work — none of it counted.

Impact

With merge enabled (YAML11_SCHEMA, or CORE_SCHEMA.withTags(mergeTag) — the standard YAML-1.1 / v4-compat configuration), the load time scales quadratically:

payload time
N=K=800 (~13 KB) ~20 ms
N=K=3200 (~50 KB) ~180 ms
N=K=20000 (~500 KB) ~13 s

A ~1 MB document projects to minutes of CPU on a single core. This restores the same availability DoS the maxTotalMergeKeys guard was shipped to prevent. A real key-folding merge of comparable size is correctly rejected today (merge keys exceeded maxTotalMergeKeys), so the guard works for the intended shape but is defeated by zero-key sources.

Attacker → control: attacker supplies an untrusted YAML document to an app that loads with merge enabled; the defeated control is maxTotalMergeKeys (default 10000).

Fix

Track whether a source folded any keys; if it folded none, charge one unit for it. A sequence of empty mappings is then bounded by maxTotalMergeKeys like any other merge.

Tests

A case in test/core/pathological.test.mjs (next to the existing merge-chain test) asserts the empty-source payload is rejected; it hangs for ~13 s and loads on the current code, and throws promptly with the fix. Normal and single empty-map merges still load correctly. Full suite green.

The maxTotalMergeKeys guard (added for the quadratic merge-key DoS,
GHSA-g796-fgmg-93mv) charges the budget once per *folded* key. A merge
source that folds no keys — an empty mapping — therefore costs real work
but never touches the counter. Aliasing one N-element sequence of empty
mappings into K merge targets does O(K*N) work while the guard never
fires:

  arr: &arr [{}, {}, ...]   # N empty maps
  targets:
    - <<: *arr              # x K

With merge enabled (YAML11_SCHEMA), an ~500 KB document of this shape
takes ~13s to load and scales quadratically, restoring the DoS the guard
was meant to prevent; a real key-folding merge of the same size is
correctly rejected.

Charge one unit for a source that folds no keys, so a sequence of empty
mappings is bounded by maxTotalMergeKeys like any other merge. Normal and
single empty-map merges are unaffected.
@puzrin
puzrin merged commit 6a8e05f into nodeca:master Aug 26, 2026
1 check passed
@puzrin

puzrin commented Aug 26, 2026

Copy link
Copy Markdown
Member

Note: next time, things will be simpler if you report such a problem via the standard way instead of a PR https://github.com/nodeca/js-yaml?tab=security-ov-file.

The cost of writing code is much lower than that of PR processing. Especially when a PR needs to be reviewed urgently and there's no option to request improvements.

puzrin added a commit that referenced this pull request Aug 26, 2026
@puzrin

puzrin commented Aug 26, 2026

Copy link
Copy Markdown
Member

See dfd3a29

@openshift-merge-robot

This comment has been minimized.

@openshift-merge-robot

This comment was marked as off-topic.

opendroid pushed a commit to opendroid/the-infinity that referenced this pull request Sep 9, 2026
Security fix: empty mappings in merge sequences now count toward
maxTotalMergeKeys, and merge sequence size is hard-limited to 100,
bounding CPU usage (nodeca/js-yaml#797).
MatiasFernandez added a commit to GemTalk/Jasper that referenced this pull request Sep 9, 2026
The dependencyVulnFloors test asserted two things about checked-in JSON:
that package.json still declares the `fast-uri` and `js-yaml` overrides,
and that every copy the lockfile resolves sits at or above a hardcoded
floor. Neither earns its keep.

The first is a change detector — it only fails when someone deliberately
edits the line, and then tells them they edited the line they just
edited. The second freezes an advisory snapshot from the day it was
written. A floor is a ratchet with a manual crank, and nothing turns it:
when the world moves past the floor the test does not go red, it goes
vacuously green, which is the worst failure mode a guard can have. That
already happened — `fast-uri`'s override moved to ^3.1.7 in #544 while
its floor stayed at 3.1.4, and #582 moves `js-yaml` to ^4.3.2 (the
backport of the merge-key limits, nodeca/js-yaml#797) against a floor
still reading 4.3.0, so the check would now accept a refresh back to a
version with a known unpatched CPU-DoS. Keeping it honest means
remembering to edit a constant on every Dependabot bump, and two bumps
in, we already did not.

It was also mis-housed: a repo-policy check over package.json and
package-lock.json, sitting in the client vitest suite that wants a live
stone, when its actual neighbours are `lint:lockfile` and
`lint:supply-chain`.

Nothing replaces it for now. Dependabot alerts already watch the same
advisory database with data that stays current, and they arrive as PRs
we can merge rather than as a constant someone has to maintain by hand.
Knapp-Kevin pushed a commit to MythologIQ-Labs-LLC/FailSafe that referenced this pull request Sep 10, 2026
…456)

Security-motivated patch bump: hard-limits YAML merge-sequence size and counts empty mappings toward maxTotalMergeKeys, fixing a CPU-exhaustion DoS vector (nodeca/js-yaml#797). Lockfile-only diff, no protected surface touched. 8/9 required checks green (CodeQL neutral/informational), mergeable_state clean at base main c369cee.
thomas-hochbichler added a commit to thomas-hochbichler/obsidian-remarkable-tagged-sync that referenced this pull request Sep 10, 2026
A dev-only transitive dependency, and the same one-line lockfile change
Dependabot proposed in #140. 4.3.2 backports two limits on YAML merge keys from
v5.4.1: a hard cap of 100 on a merge sequence, and empty mappings counted
toward maxTotalMergeKeys so they cannot be used to burn CPU (nodeca/js-yaml#797).

Reproduced by hand rather than merged from #140 because the Git identity gate
checks every commit a branch adds to main against one address, and a Dependabot
commit can never carry it. That makes every Dependabot PR structurally red here,
which is worth deciding about separately -- this commit only takes the bump.

`npm run matrix` reads the test matrix through js-yaml and still passes; the
full suite is green.


Claude-Session: https://claude.ai/code/session_0134WoHk57opQQAtELDmPorU

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants