Skip to content

Dos in merge key#937

Merged
ingydotnet merged 10 commits into
mainfrom
dos-in-merge-key
Jun 17, 2026
Merged

Dos in merge key#937
ingydotnet merged 10 commits into
mainfrom
dos-in-merge-key

Conversation

@ingydotnet

Copy link
Copy Markdown
Member

Resolves #897
Supercedes #916

Summary

The merge key (<<) constructor implementation in
SafeConstructor.flatten_mapping() was vulnerable to an
exponential time and memory complexity Denial of Service (DoS)
vulnerability. When mapping/sequence nodes are merged using
anchors/aliases, duplicate references to the same alias point
to the same MappingNode instance in Python. During merge key
processing, the node values are copied and extended in-place.
If the same node appears multiple times at different levels,
this causes exponential amplification of the elements list:
2^(n+1) - 1.

A small document under 1 KB can trigger millions of element
list extensions, exhausting CPU and memory during safe loading.

Hardened Fix

This commit resolves the vulnerability and hardens it against
secondary vectors:

  1. Tracks node identity using object ID (id(node)) in a single
    seen set scoped to the parent mapping's flatten_mapping()
    execution.
  2. Checks and skips duplicate node references inside SequenceNode
    merge keys (resolving PR-916).
  3. Checks and skips duplicate node references across separate,
    independent MappingNode merge keys in the same mapping (e.g.,
    repeating <<: *anchor multiple times).
  4. Ensures C-based loaders (e.g., CSafeLoader, CLoader) are
    also protected since they inherit constructor logic from
    SafeConstructor.

Performance Impact

  • Sequence-nested merge duplicates: Loading a 22-level nested
    document drops from 3.76s to 0.0028s (O(N) linear complexity).
  • Mapping-level merge duplicates: Loading a 20-level nested
    document drops from 0.93s to 0.0026s.

Tests

  • Added regression tests to
    tests/legacy_tests/data/construct-merge.data and
    tests/legacy_tests/data/construct-merge.code covering both
    duplicate sequence merges and duplicate direct merges.

ingydotnet and others added 3 commits June 15, 2026 14:49
Most developer machines do not have libyaml headers installed, so the
old default test flow attempted to build the optional extension, failed
to find yaml.h, and then only exercised the pure Python fallback. The
extension target also required out-of-band system setup before it could
pass.

Split the Makefile test flow into explicit pure-Python and LibYAML
targets, and make the default test target run both. The pure-Python
target now forces the extension off during build, while the LibYAML
target builds a pinned local yaml/libyaml checkout and wires the
include, library, and runtime paths into the extension build.

Document the new Makefile workflow in the README, including make test,
the individual test targets, the pinned LIBYAML-REF default, and the
PYTHON-VERSION override for testing against a specific Python.

This makes both test modes pass from a clean checkout without requiring
developers to install libyaml development packages by hand.
Resolves #897
Supercedes #916

### Summary
The merge key (`<<`) constructor implementation in
`SafeConstructor.flatten_mapping()` was vulnerable to an
exponential time and memory complexity Denial of Service (DoS)
vulnerability. When mapping/sequence nodes are merged using
anchors/aliases, duplicate references to the same alias point
to the same MappingNode instance in Python. During merge key
processing, the node values are copied and extended in-place.
If the same node appears multiple times at different levels,
this causes exponential amplification of the elements list:
`2^(n+1) - 1`.

A small document under 1 KB can trigger millions of element
list extensions, exhausting CPU and memory during safe loading.

### Hardened Fix
This commit resolves the vulnerability and hardens it against
secondary vectors:
1. Tracks node identity using object ID (`id(node)`) in a single
`seen` set scoped to the parent mapping's `flatten_mapping()`
execution.
2. Checks and skips duplicate node references inside SequenceNode
merge keys (resolving PR-916).
3. Checks and skips duplicate node references across separate,
independent MappingNode merge keys in the same mapping (e.g.,
repeating `<<: *anchor` multiple times).
4. Ensures C-based loaders (e.g., `CSafeLoader`, `CLoader`) are
also protected since they inherit constructor logic from
`SafeConstructor`.

### Performance Impact
- Sequence-nested merge duplicates: Loading a 22-level nested
document drops from 3.76s to 0.0028s (O(N) linear complexity).
- Mapping-level merge duplicates: Loading a 20-level nested
document drops from 0.93s to 0.0026s.

### Tests
- Added regression tests to
`tests/legacy_tests/data/construct-merge.data` and
`tests/legacy_tests/data/construct-merge.code` covering both
duplicate sequence merges and duplicate direct merges.
@ingydotnet

Copy link
Copy Markdown
Member Author

@nitzmahone @frenzymadness Please review.

I added a Makefile reworking commit here because make test didn't work for me or @aaronbronow. Now it will work out of the box for anybody. Take a look at the readme for details.

The PR was failing before it reached the PyYAML regression tests in two
places: the cp38 wheel jobs installed latest cibuildwheel, whose 4.x
series no longer supports Python 3.8, and the Windows arm64 libyaml build
used a newer CMake that rejects libyaml 0.2.5's old minimum policy version.

Pin the cp38 wheel jobs to cibuildwheel<4, and quote the matrix-provided
pip package spec so bash does not parse the '<4' constraint as input
redirection. Pass CMAKE_POLICY_VERSION_MINIMUM=3.5 to the Windows libyaml
configure step, and disable fail-fast for the Windows libyaml matrix so
one architecture does not hide the others.
@nitzmahone

Copy link
Copy Markdown
Member

This change only eliminates literal inline duplication within the same collection- the underlying problem with nested anchors as described on the comment on #916 still exists. The sample document provided there doesn't trigger this optimization and still results in exponential work, which makes the security fix incomplete.

The proposed fix referenced in the comment does take care of the nested case, but also has some issues- the biggest being that it fails on non-hashable keys. It's moot for built-in Python dict anyway, since its keys must be hashable (so you wouldn't even make it this far), but it's legal in YAML and a custom mapping can support it.

I'm generally not a fan of id based identity optimization in Python serialization (been nailed by it many times), but in this case, the two approaches could probably be combined since the anchor referent node (and its child identities) should remain static throughout the parse.

Comment thread lib/yaml/constructor.py Outdated

@nitzmahone nitzmahone left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other misc notes to take or leave...

Comment thread .github/workflows/ci.yaml

pushd libyaml/build
cmake.exe -G "Visual Studio 17 2022" -A ${{ matrix.arch }} -DYAML_STATIC_LIB_NAME=yaml ..
cmake.exe -G "Visual Studio 17 2022" -A ${{ matrix.arch }} -DYAML_STATIC_LIB_NAME=yaml -DCMAKE_POLICY_VERSION_MINIMUM=3.5 ..

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this is to shut up a warning on an old libyaml dist, but shouldn't it just be updated in libyaml's CMakeLists.txt instead of done on this end?

Comment thread tests/legacy_tests/data/construct-merge.data
Comment thread Makefile
Comment thread Makefile

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't this just be replaced with tox or nox like what most of the Python projects have by convention?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer to use Makes for all my projects in all languages as it is zero dep.
iow, Python isn't required and testing against any Python version on any machine is just:

make test
make test PYTHON-VERSION=3.14.0

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said, if you want to submit a PR to support tox or nox as well, I have no problem with that.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why maintain multiple configurations for working with the project? That leads to increased maintenance problems, with different contributors only able to effectively maintain the configurations for the tool(s) they use.

Since this is a Python project, it seems reasonable to standardize on one tool that's well supported within the Python ecosystem. While it won't fit everyone's preferred workflow, doing so will maximize the ability of the community as a whole to effectively contribute.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The structural changes around build/test/CI workflows to properly support abi3/abi3t make a lot of sense to do with something like tox/nox anyway- re-layering the Makefile targets to point at the various apropos tox/nox sessions once that's available should alleviate my concerns about the current inline logic not matching the shipping bits.

@aaronbronow aaronbronow Jun 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maximize the ability of the community as a whole to effectively contribute

This should definitely be a guiding principal of all workflow and build environment changes.

concerns about the current inline logic not matching the shipping bits

I've been learning a lot about deterministic, repeatable build processes (like are the bits the same every run if code wasn't changed), and standardizing builds generate attested artifacts. My goal is to propose a solution that has little or no workflow impact for contributors.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aaronbronow if you're interested, I'm working on a tox-centric ecosystem for CI/CD/infra. The GHA UX looks like this: https://github.com/ansible/awx-plugins/blob/a5523a20b5420cd93799cee0d2bc2623a841c177/.github/workflows/ci-cd.yml#L498-L550. I've also got a bunch of tox plugins (it's a novel thing) that bring shareable tox envs with a single pip install (or adding to requires in the config) instead of copying configs across projects.
It's a bit nuanced w/ C-ext projects but works rather well w/ pure-python.

@nitzmahone nitzmahone Jun 17, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some unique challenges to bit-perfect deterministic builds on this project, mostly related to the ancient custom setuptools (née distutils) + Cython extension build. There are other promising options out there (scikit-build-core, Meson) that might make it easier, but it'll require a rewrite of the extension build and killing off the expectation of on automatic "best-effort" extension build that's existed for the project's entire life (and at least scikit-build-core is a very young "pre-1.0" project, last I checked).

@ingydotnet

Copy link
Copy Markdown
Member Author

30ed0df now has tests that failed before the (relatively small) fix and passed after.

If anyone thinks there are still cases we haven't considered, please suggest a test that fails before and after this fix. Or at least after.

Comment thread lib/yaml/constructor.py Outdated
@nitzmahone

nitzmahone commented Jun 16, 2026

Copy link
Copy Markdown
Member

I don't see any implementation change since yesterday that addresses the originally-referenced nested repro- it still results in exponential compute expansion.

The tests added today did reveal a last-minute screwup I made in the suggested hybrid fix though- tried to optimize away the set creation in non-merge cases, but that breaks if we hit it > 1 time for a given mapping. I posted an updated suggestion that passes all tests (including those just added) and yields a result for the original nested repro orders of magnitude faster.

Hardcoded empirical timeouts on unit tests always make me nervous- at a glance, they don't seem to be failing as expected (since the nested exponential compute expansion still exists), and unless the chasm between pass/fail is at least a couple orders of magnitude wide, they're likely to cause problems on our emulated arches (which run ~ an order of magnitude slower than the real hardware).

Replace the timing-based merge DoS tests with deterministic checks of
the flattened mapping shape. Wall-clock thresholds are fragile on slow
or emulated CI runners and do not directly prove that the expansion was
prevented.

The fan-out regression case uses a small levels=4, width=4 payload from
the PR #916 shape. With the old behavior, flattening root expands to
1024 repeated key/value pairs and the test fails immediately because the
key list contains 1020 extra entries. With the fix, duplicate merge pairs
are collapsed during flattening and the same root mapping contains only
k0, k1, k2, and k3.

This keeps the test fast even if the old bug returns: it observes the
structural amplification directly instead of waiting for a large
pathological input to time out.
@ingydotnet
ingydotnet requested a review from nitzmahone June 17, 2026 17:54
* A hybrid combination of fixes proposed by frenzymadness and akshat-sj to prevent exponential compute while resolving nested anchor aliases.
* Further optimization is possible with broader caching of anchor node graph, but would require careful design of invalidation policy.
@ingydotnet
ingydotnet merged commit 34a9bf8 into main Jun 17, 2026
66 checks passed
@ingydotnet
ingydotnet deleted the dos-in-merge-key branch June 17, 2026 22:18
@tylzh97

tylzh97 commented Jun 18, 2026

Copy link
Copy Markdown

Thanks for fixing this! I’ve verified that the #897 has been resolved. 👍

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.

[Bug]: YAML Merge Keys Drive Exponential Expansion DoS

7 participants