Skip to content

Commit 71b6174

Browse files
Copilotbaywet
andauthored
fix(readers): bound YAML anchor/alias expansion to prevent OOM (billion laughs)
Ports the fix merged on main (#3000) to the support/v1 reader, which walks the SharpYaml node graph directly. Aliases share a single source node, so a tiny document expands exponentially when materialized into independent OpenApi any trees, exhausting process memory (CWE-400). Adds a per-parse node budget enforced by ParsingContext and a nesting depth limit enforced while materializing any values. Limits are configurable through the new OpenApiReaderLimits type and default to 5,000,000 nodes and depth 64 (mirroring the System.Text.Json default) as on main. Co-authored-by: baywet <7905502+baywet@users.noreply.github.com>
1 parent 95a77d8 commit 71b6174

8 files changed

Lines changed: 261 additions & 7 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
4+
using System;
5+
6+
namespace Microsoft.OpenApi.Readers
7+
{
8+
/// <summary>
9+
/// Resource limits applied while reading an OpenAPI description, protecting the reader from
10+
/// hostile documents that would otherwise exhaust memory or the stack.
11+
/// </summary>
12+
public static class OpenApiReaderLimits
13+
{
14+
/// <summary>
15+
/// Default maximum nesting depth allowed when materializing values from a YAML/JSON node graph.
16+
/// Mirrors the default System.Text.Json depth limit (64), protecting the recursive readers
17+
/// from stack exhaustion on deeply nested documents.
18+
/// </summary>
19+
public const uint DefaultMaxDepth = 64;
20+
21+
/// <summary>
22+
/// Default maximum number of nodes that may be materialized from a single document.
23+
/// Guards against YAML anchor/alias expansion ("billion laughs") attacks, where a tiny document
24+
/// expands exponentially when its shared node graph is materialized into an independent tree.
25+
/// </summary>
26+
public const uint DefaultMaxNodeCount = 5_000_000;
27+
28+
private static uint _maxDepth = DefaultMaxDepth;
29+
private static uint _maxNodeCount = DefaultMaxNodeCount;
30+
31+
/// <summary>
32+
/// Gets or sets the maximum nesting depth allowed when materializing values from a node graph.
33+
/// Defaults to <see cref="DefaultMaxDepth"/>. Raise this if legitimate deeply nested documents are
34+
/// being rejected, or lower it to fail faster when only shallow documents are expected.
35+
/// </summary>
36+
/// <exception cref="ArgumentOutOfRangeException">Thrown when set to zero.</exception>
37+
public static uint MaxDepth
38+
{
39+
get => _maxDepth;
40+
set
41+
{
42+
if (value == 0)
43+
{
44+
throw new ArgumentOutOfRangeException(nameof(value), "MaxDepth must be greater than zero.");
45+
}
46+
47+
_maxDepth = value;
48+
}
49+
}
50+
51+
/// <summary>
52+
/// Gets or sets the maximum number of nodes that may be materialized from a single document.
53+
/// Defaults to <see cref="DefaultMaxNodeCount"/>, guarding against YAML anchor/alias expansion
54+
/// ("billion laughs") attacks. Raise this if legitimate large documents are being rejected, or lower
55+
/// it to fail faster when only small documents are expected.
56+
/// </summary>
57+
/// <exception cref="ArgumentOutOfRangeException">Thrown when set to zero.</exception>
58+
public static uint MaxNodeCount
59+
{
60+
get => _maxNodeCount;
61+
set
62+
{
63+
if (value == 0)
64+
{
65+
throw new ArgumentOutOfRangeException(nameof(value), "MaxNodeCount must be greater than zero.");
66+
}
67+
68+
_maxNodeCount = value;
69+
}
70+
}
71+
}
72+
}

src/Microsoft.OpenApi.Readers/ParseNodes/ListNode.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,13 @@ IEnumerator IEnumerable.GetEnumerator()
6464
/// Create a <see cref="OpenApiArray"/>
6565
/// </summary>
6666
/// <returns>The created Any object.</returns>
67-
public override IOpenApiAny CreateAny()
67+
internal override IOpenApiAny CreateAny(uint depth)
6868
{
69+
EnsureDepthWithinLimit(depth);
6970
var array = new OpenApiArray();
7071
foreach (var node in this)
7172
{
72-
array.Add(node.CreateAny());
73+
array.Add(node.CreateAny(depth + 1));
7374
}
7475

7576
return array;

src/Microsoft.OpenApi.Readers/ParseNodes/MapNode.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,12 +213,13 @@ public string GetScalarValue(ValueNode key)
213213
/// Create a <see cref="OpenApiObject"/>
214214
/// </summary>
215215
/// <returns>The created Any object.</returns>
216-
public override IOpenApiAny CreateAny()
216+
internal override IOpenApiAny CreateAny(uint depth)
217217
{
218+
EnsureDepthWithinLimit(depth);
218219
var apiObject = new OpenApiObject();
219220
foreach (var node in this)
220221
{
221-
apiObject.Add(node.Name, node.Value.CreateAny());
222+
apiObject.Add(node.Name, node.Value.CreateAny(depth + 1));
222223
}
223224

224225
return apiObject;

src/Microsoft.OpenApi.Readers/ParseNodes/ParseNode.cs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ internal abstract class ParseNode
1616
protected ParseNode(ParsingContext parsingContext)
1717
{
1818
Context = parsingContext;
19+
Context?.CountNode();
1920
}
2021

2122
public ParsingContext Context { get; }
@@ -73,11 +74,32 @@ public virtual Dictionary<string, T> CreateSimpleMap<T>(Func<ValueNode, T> map)
7374
throw new OpenApiReaderException("Cannot create simple map from this type of node.", Context);
7475
}
7576

76-
public virtual IOpenApiAny CreateAny()
77+
public IOpenApiAny CreateAny()
78+
{
79+
return CreateAny(0);
80+
}
81+
82+
/// <summary>
83+
/// Materializes the node, and everything below it, into an <see cref="IOpenApiAny"/>.
84+
/// </summary>
85+
/// <param name="depth">Nesting depth of the current node, bounded by <see cref="OpenApiReaderLimits.MaxDepth"/>.</param>
86+
internal virtual IOpenApiAny CreateAny(uint depth)
7787
{
7888
throw new OpenApiReaderException("Cannot create an Any object this type of node.", Context);
7989
}
8090

91+
/// <summary>
92+
/// Fails fast when the node graph is nested more deeply than the reader supports,
93+
/// protecting the recursive readers from stack exhaustion.
94+
/// </summary>
95+
protected void EnsureDepthWithinLimit(uint depth)
96+
{
97+
if (depth > OpenApiReaderLimits.MaxDepth)
98+
{
99+
throw new OpenApiReaderException($"The document exceeds the maximum supported nesting depth of {OpenApiReaderLimits.MaxDepth}.", Context);
100+
}
101+
}
102+
81103
public virtual string GetRaw()
82104
{
83105
throw new OpenApiReaderException("Cannot get raw value from this type of node.", Context);

src/Microsoft.OpenApi.Readers/ParseNodes/PropertyNode.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ public void ParseField<T>(
8282
}
8383
}
8484

85-
public override IOpenApiAny CreateAny()
85+
internal override IOpenApiAny CreateAny(uint depth)
8686
{
8787
throw new NotImplementedException();
8888
}

src/Microsoft.OpenApi.Readers/ParseNodes/ValueNode.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ public override string GetScalarValue()
3131
/// Create a <see cref="IOpenApiPrimitive"/>
3232
/// </summary>
3333
/// <returns>The created Any object.</returns>
34-
public override IOpenApiAny CreateAny()
34+
internal override IOpenApiAny CreateAny(uint depth)
3535
{
36+
EnsureDepthWithinLimit(depth);
3637
var value = GetScalarValue();
3738
return new OpenApiString(value, this._node.Style is ScalarStyle.SingleQuoted or ScalarStyle.DoubleQuoted or ScalarStyle.Literal or ScalarStyle.Folded);
3839
}

src/Microsoft.OpenApi.Readers/ParsingContext.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public class ParsingContext
2525
private readonly Dictionary<string, object> _tempStorage = new();
2626
private readonly Dictionary<object, Dictionary<string, object>> _scopedTempStorage = new();
2727
private readonly Dictionary<string, Stack<string>> _loopStacks = new();
28+
private uint _nodeCount;
2829
internal Dictionary<string, Func<IOpenApiAny, OpenApiSpecVersion, IOpenApiExtension>> ExtensionParsers { get; set; } = new();
2930
internal RootNode RootNode { get; set; }
3031
internal List<OpenApiTag> Tags { get; private set; } = new();
@@ -198,6 +199,20 @@ public void StartObject(string objectName)
198199
_currentLocation.Push(objectName);
199200
}
200201

202+
/// <summary>
203+
/// Counts a node materialized while parsing the current document and fails fast when the
204+
/// document expands beyond <see cref="OpenApiReaderLimits.MaxNodeCount"/>. YAML anchors and
205+
/// aliases share a single node in the source graph, so a tiny document can expand
206+
/// exponentially ("billion laughs") when it is materialized into an independent tree.
207+
/// </summary>
208+
internal void CountNode()
209+
{
210+
if (++_nodeCount > OpenApiReaderLimits.MaxNodeCount)
211+
{
212+
throw new OpenApiReaderException($"The document expands to more than the maximum supported number of nodes ({OpenApiReaderLimits.MaxNodeCount}). This may indicate a YAML anchor/alias expansion (billion laughs) attack.");
213+
}
214+
}
215+
201216
/// <summary>
202217
/// Maintain history of traversals to avoid stack overflows from cycles
203218
/// </summary>
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
4+
using System;
5+
using FluentAssertions;
6+
using Microsoft.OpenApi.Any;
7+
using Microsoft.OpenApi.Readers;
8+
using Microsoft.OpenApi.Readers.Exceptions;
9+
using Microsoft.OpenApi.Readers.ParseNodes;
10+
using Xunit;
11+
12+
namespace Microsoft.OpenApi.Tests
13+
{
14+
[Collection("DefaultSettings")]
15+
public class YamlAliasExpansionTests
16+
{
17+
// A "billion laughs" YAML bomb: each level references the previous one multiple times,
18+
// so materializing the shared node graph into an independent object tree expands
19+
// exponentially. The conversion must fail fast instead of exhausting memory.
20+
private const string YamlBomb =
21+
"""
22+
a: &a ["x","x","x","x","x","x","x","x","x"]
23+
b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]
24+
c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]
25+
d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]
26+
e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d]
27+
f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e]
28+
g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f]
29+
h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g]
30+
i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h]
31+
""";
32+
33+
[Fact]
34+
public void ExponentialAliasExpansionIsRejected()
35+
{
36+
var node = ParseNode.Create(new(new()), YamlHelper.ParseYamlString(YamlBomb));
37+
38+
Assert.Throws<OpenApiReaderException>(() => node.CreateAny());
39+
}
40+
41+
[Fact]
42+
public void ExcessiveNestingDepthIsRejected()
43+
{
44+
// Deeper than the conversion depth limit, which protects the recursive
45+
// converter from stack exhaustion.
46+
const int depth = 70;
47+
var deeplyNested = new string('[', depth) + new string(']', depth);
48+
49+
var node = ParseNode.Create(new(new()), YamlHelper.ParseYamlString(deeplyNested));
50+
51+
Assert.Throws<OpenApiReaderException>(() => node.CreateAny());
52+
}
53+
54+
[Fact]
55+
public void ReadReturnsDiagnosticErrorForExponentialAliasExpansion()
56+
{
57+
// A "billion laughs" YAML bomb must surface as a diagnostic error
58+
// rather than throwing or exhausting memory.
59+
var input =
60+
$$"""
61+
openapi: 3.0.0
62+
info:
63+
title: bomb
64+
version: 1.0.0
65+
paths: {}
66+
x-bomb:
67+
{{YamlBombIndented()}}
68+
""";
69+
70+
var reader = new OpenApiStringReader();
71+
reader.Read(input, out var diagnostic);
72+
73+
diagnostic.Errors.Should().NotBeEmpty();
74+
}
75+
76+
[Fact]
77+
public void LegitimateAliasesStillConvert()
78+
{
79+
var input =
80+
"""
81+
a: &val hello
82+
b: *val
83+
""";
84+
85+
var node = ParseNode.Create(new(new()), YamlHelper.ParseYamlString(input));
86+
87+
var anyObject = Assert.IsType<OpenApiObject>(node.CreateAny());
88+
Assert.Equal("hello", ((OpenApiString)anyObject["a"]).Value);
89+
Assert.Equal("hello", ((OpenApiString)anyObject["b"]).Value);
90+
}
91+
92+
[Fact]
93+
public void ConversionLimitsDefaultToDocumentedValues()
94+
{
95+
Assert.Equal(64u, OpenApiReaderLimits.DefaultMaxDepth);
96+
Assert.Equal(5_000_000u, OpenApiReaderLimits.DefaultMaxNodeCount);
97+
Assert.Equal(OpenApiReaderLimits.DefaultMaxDepth, OpenApiReaderLimits.MaxDepth);
98+
Assert.Equal(OpenApiReaderLimits.DefaultMaxNodeCount, OpenApiReaderLimits.MaxNodeCount);
99+
}
100+
101+
[Fact]
102+
public void SettingMaxDepthToZeroThrows()
103+
{
104+
Assert.Throws<ArgumentOutOfRangeException>(() => OpenApiReaderLimits.MaxDepth = 0);
105+
// The invalid assignment must not have changed the effective limit.
106+
Assert.Equal(OpenApiReaderLimits.DefaultMaxDepth, OpenApiReaderLimits.MaxDepth);
107+
}
108+
109+
[Fact]
110+
public void SettingMaxNodeCountToZeroThrows()
111+
{
112+
Assert.Throws<ArgumentOutOfRangeException>(() => OpenApiReaderLimits.MaxNodeCount = 0);
113+
// The invalid assignment must not have changed the effective limit.
114+
Assert.Equal(OpenApiReaderLimits.DefaultMaxNodeCount, OpenApiReaderLimits.MaxNodeCount);
115+
}
116+
117+
[Fact]
118+
public void RaisingMaxDepthAllowsDocumentsDeeperThanTheDefault()
119+
{
120+
// A document nested deeper than the default depth limit (64) is rejected by default
121+
// but can be permitted by a consumer that opts into a higher limit.
122+
const int depth = 70;
123+
var deeplyNested = new string('[', depth) + new string(']', depth);
124+
125+
try
126+
{
127+
OpenApiReaderLimits.MaxDepth = (uint)(depth + 10);
128+
var node = ParseNode.Create(new(new()), YamlHelper.ParseYamlString(deeplyNested));
129+
Assert.IsType<OpenApiArray>(node.CreateAny());
130+
}
131+
finally
132+
{
133+
OpenApiReaderLimits.MaxDepth = OpenApiReaderLimits.DefaultMaxDepth;
134+
}
135+
}
136+
137+
private static string YamlBombIndented()
138+
{
139+
return " " + YamlBomb.Replace("\r\n", "\n").Replace("\n", "\n ");
140+
}
141+
}
142+
}

0 commit comments

Comments
 (0)