Skip to content

A self-referencing YAML anchor in frontmatter crashes the process with StackOverflow; nested aliases expand exponentially #159

Description

@matt-edmondson

What's wrong

YamlDotNet resolves each alias to the same object reference, and an alias can point back at its own anchor. That means a deserialized frontmatter value can be a cyclic List<object> / Dictionary<object, object>.

YamlSerializer.ConvertValue (Frontmatter/YamlSerializer.cs:158-170) and DeepCloneValue (:139-153) copy this graph recursively, with no visited set, depth limit or node budget. TryParseYamlObject (:72-90) reaches them for every block.

  • With a cycle: the recursion runs until the stack overflows. .NET cannot catch a StackOverflowException, so the whole host process dies. The existing catch (YamlException/InvalidOperationException/ArgumentException) blocks never get a chance to run.
  • Without a cycle: each alias is expanded in full at every reference, so nested aliases grow exponentially ("billion laughs"). Serialization then writes the fully expanded tree back out.

Reproduction

Cycle:

Frontmatter.CombineFrontmatter("---\na: &x [1, *x]\n---\nbody\n",
    FrontmatterNaming.AsIs, FrontmatterOrder.AsIs, FrontmatterMergeStrategy.None);
// or
Frontmatter.ExtractFrontmatter("---\na: &x {b: *x}\n---\nbody\n");

The test host exited with code 134 and Stack overflow.. The stack frames alternate between YamlSerializer.ConvertValue and Enumerable.ToList / ToDictionary.

Expansion: I used a block of about 350 bytes with 6 alias levels:

a: &a [x, x, x, x, x, x, x, x, x, x]
b: &b [*a, *a, *a, *a, *a, *a, *a, *a, *a, *a]
# … up to f
  • ExtractFrontmatter took about 150 ms and built 1,000,000 list nodes.
  • CombineFrontmatter took about 2.3 s and returned a 15.3M-character string.
  • Each extra level multiplies both figures by about 10, so 8–9 levels runs out of memory.

Why it matters

The library is built for processing markdown files in bulk, such as site generators and vault tools. One malicious or accidental file can kill the host process or exhaust its memory, and the caller has no way to catch the failure.

Suggested fix

  • Reject cycles. Track the containers on the current recursion path with a ReferenceEqualityComparer set in ConvertValue / DeepCloneValue. If a cycle is found, TryParseYamlObject should return false.
  • Cap expansion. Enforce a total node budget, e.g. 100k. Alternatively, reject aliases outright, since frontmatter almost never uses them. One way is to scan the IParser event stream for AnchorAlias before deserializing.
  • Add regression tests for a self-referencing sequence, a self-referencing mapping, and the 6-level alias bomb. Each should return quickly, leave the block unchanged or report it as unreadable, and not crash.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingreadyFully specified; implement as written

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions