Skip to content

Commit 2179326

Browse files
TreicysgTreicy Sanchez Gutierrez (from Dev Box)Copilot
authored
fix: bound YAML anchor/alias expansion to prevent OOM (billion laughs) (#3000)
* fix: bound YAML anchor/alias expansion to prevent OOM (billion laughs) The YAML reader converts the SharpYaml node graph - a DAG in which aliases share a single instance - into a System.Text.Json JsonNode tree, allocating a fresh node per path. Because JsonNode is single-parent, shared aliases must be duplicated, so a tiny document with nested anchors/aliases expands exponentially and exhausts process memory (CWE-400, uncontrolled resource consumption). Add a conversion budget to YamlConverter.ToJsonNode that caps the total materialized node count (5,000,000) and nesting depth (64, mirroring the System.Text.Json default already enforced on the JSON reader path). On breach it throws OpenApiReaderException, which OpenApiYamlReader.Read converts into an OpenApiDiagnostic error instead of allowing an OOM. Public API is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 022bbd4f-e5e7-447a-bcdf-b2a4efaf75c3 * feat: make YAML conversion limits configurable Expose YamlConverter.MaxDepth and MaxNodeCount as public static properties (defaulting to DefaultMaxDepth=64 and DefaultMaxNodeCount=5,000,000) so consumers can raise the limits for legitimately large/deep documents or lower them to fail faster on known-small inputs, without needing a library change. Setters validate that the value is greater than zero. Public API entries added. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 022bbd4f-e5e7-447a-bcdf-b2a4efaf75c3 * uint instead --------- Co-authored-by: Treicy Sanchez Gutierrez (from Dev Box) <treicys@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 022bbd4f-e5e7-447a-bcdf-b2a4efaf75c3
1 parent 2a1c346 commit 2179326

5 files changed

Lines changed: 244 additions & 4 deletions

File tree

src/Microsoft.OpenApi.YamlReader/OpenApiYamlReader.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,17 @@ public ReadResult Read(MemoryStream input,
7474
Diagnostic = diagnostic,
7575
};
7676
}
77+
catch (OpenApiReaderException ex)
78+
{
79+
var diagnostic = new OpenApiDiagnostic();
80+
diagnostic.Errors.Add(new(ex));
81+
diagnostic.Format = OpenApiConstants.Yaml;
82+
return new()
83+
{
84+
Document = null,
85+
Diagnostic = diagnostic,
86+
};
87+
}
7788

7889
return UpdateFormat(Read(jsonNode, location, settings));
7990
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
11
#nullable enable
2+
const Microsoft.OpenApi.YamlReader.YamlConverter.DefaultMaxDepth = 64 -> uint
3+
const Microsoft.OpenApi.YamlReader.YamlConverter.DefaultMaxNodeCount = 5000000 -> uint
4+
static Microsoft.OpenApi.YamlReader.YamlConverter.MaxDepth.get -> uint
5+
static Microsoft.OpenApi.YamlReader.YamlConverter.MaxDepth.set -> void
6+
static Microsoft.OpenApi.YamlReader.YamlConverter.MaxNodeCount.get -> uint
7+
static Microsoft.OpenApi.YamlReader.YamlConverter.MaxNodeCount.set -> void

src/Microsoft.OpenApi.YamlReader/YamlConverter.cs

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,94 @@ namespace Microsoft.OpenApi.YamlReader
1414
/// </summary>
1515
public static class YamlConverter
1616
{
17+
/// <summary>
18+
/// Default maximum nesting depth allowed when converting a YAML node graph into JSON nodes.
19+
/// Mirrors the default System.Text.Json depth limit (64) that already bounds the JSON reader path,
20+
/// protecting the recursive conversion from stack exhaustion on deeply nested documents.
21+
/// </summary>
22+
public const uint DefaultMaxDepth = 64;
23+
24+
/// <summary>
25+
/// Default maximum number of JSON nodes that may be materialized from a single YAML document.
26+
/// Guards against YAML anchor/alias expansion ("billion laughs") attacks, where a tiny document
27+
/// expands exponentially when its shared node graph is materialized into an independent JSON tree.
28+
/// </summary>
29+
public const uint DefaultMaxNodeCount = 5_000_000;
30+
31+
private static uint _maxDepth = DefaultMaxDepth;
32+
private static uint _maxNodeCount = DefaultMaxNodeCount;
33+
34+
/// <summary>
35+
/// Gets or sets the maximum nesting depth allowed when converting a YAML node graph into JSON nodes.
36+
/// Defaults to <see cref="DefaultMaxDepth"/>. Raise this if legitimate deeply nested documents are
37+
/// being rejected, or lower it to fail faster when only shallow documents are expected.
38+
/// </summary>
39+
/// <exception cref="ArgumentOutOfRangeException">Thrown when set to zero.</exception>
40+
public static uint MaxDepth
41+
{
42+
get => _maxDepth;
43+
set
44+
{
45+
if (value == 0)
46+
{
47+
throw new ArgumentOutOfRangeException(nameof(value), "MaxDepth must be greater than zero.");
48+
}
49+
50+
_maxDepth = value;
51+
}
52+
}
53+
54+
/// <summary>
55+
/// Gets or sets the maximum number of JSON nodes that may be materialized from a single YAML document.
56+
/// Defaults to <see cref="DefaultMaxNodeCount"/>, guarding against YAML anchor/alias expansion
57+
/// ("billion laughs") attacks. Raise this if legitimate large documents are being rejected, or lower
58+
/// it to fail faster when only small documents are expected.
59+
/// </summary>
60+
/// <exception cref="ArgumentOutOfRangeException">Thrown when set to zero.</exception>
61+
public static uint MaxNodeCount
62+
{
63+
get => _maxNodeCount;
64+
set
65+
{
66+
if (value == 0)
67+
{
68+
throw new ArgumentOutOfRangeException(nameof(value), "MaxNodeCount must be greater than zero.");
69+
}
70+
71+
_maxNodeCount = value;
72+
}
73+
}
74+
75+
/// <summary>
76+
/// Tracks and enforces resource limits while converting a YAML node graph into JSON nodes,
77+
/// failing fast when a hostile document would otherwise exhaust memory or the stack.
78+
/// </summary>
79+
private sealed class YamlConversionBudget
80+
{
81+
private readonly uint _maxDepth;
82+
private readonly uint _maxNodeCount;
83+
private uint _nodeCount;
84+
85+
public YamlConversionBudget(uint maxDepth, uint maxNodeCount)
86+
{
87+
_maxDepth = maxDepth;
88+
_maxNodeCount = maxNodeCount;
89+
}
90+
91+
public void EnterNode(uint depth)
92+
{
93+
if (depth > _maxDepth)
94+
{
95+
throw new OpenApiReaderException($"The YAML document exceeds the maximum supported nesting depth of {_maxDepth}.");
96+
}
97+
98+
if (++_nodeCount > _maxNodeCount)
99+
{
100+
throw new OpenApiReaderException($"The YAML document expands to more than the maximum supported number of nodes ({_maxNodeCount}). This may indicate a YAML anchor/alias expansion (billion laughs) attack.");
101+
}
102+
}
103+
}
104+
17105
/// <summary>
18106
/// Converts all of the documents in a YAML stream to <see cref="JsonNode"/>s.
19107
/// </summary>
@@ -42,10 +130,16 @@ public static JsonNode ToJsonNode(this YamlDocument yaml)
42130
/// <exception cref="NotSupportedException">Thrown for YAML that is not compatible with JSON.</exception>
43131
public static JsonNode ToJsonNode(this YamlNode yaml)
44132
{
133+
return yaml.ToJsonNode(new YamlConversionBudget(MaxDepth, MaxNodeCount), 0);
134+
}
135+
136+
private static JsonNode ToJsonNode(this YamlNode yaml, YamlConversionBudget budget, uint depth)
137+
{
138+
budget.EnterNode(depth);
45139
return yaml switch
46140
{
47-
YamlMappingNode map => map.ToJsonObject(),
48-
YamlSequenceNode seq => seq.ToJsonArray(),
141+
YamlMappingNode map => map.ToJsonObject(budget, depth),
142+
YamlSequenceNode seq => seq.ToJsonArray(budget, depth),
49143
YamlScalarNode scalar => scalar.ToJsonValue(),
50144
_ => throw new NotSupportedException("This yaml isn't convertible to JSON")
51145
};
@@ -78,12 +172,17 @@ public static YamlNode ToYamlNode(this JsonNode json)
78172
/// <param name="yaml"></param>
79173
/// <returns></returns>
80174
public static JsonObject ToJsonObject(this YamlMappingNode yaml)
175+
{
176+
return yaml.ToJsonObject(new YamlConversionBudget(MaxDepth, MaxNodeCount), 0);
177+
}
178+
179+
private static JsonObject ToJsonObject(this YamlMappingNode yaml, YamlConversionBudget budget, uint depth)
81180
{
82181
var node = new JsonObject();
83182
foreach (var keyValuePair in yaml)
84183
{
85184
var key = ((YamlScalarNode)keyValuePair.Key).Value!;
86-
node[key] = keyValuePair.Value.ToJsonNode();
185+
node[key] = keyValuePair.Value.ToJsonNode(budget, depth + 1);
87186
}
88187

89188
return node;
@@ -103,11 +202,16 @@ private static YamlMappingNode ToYamlMapping(this JsonObject obj)
103202
/// <param name="yaml"></param>
104203
/// <returns></returns>
105204
public static JsonArray ToJsonArray(this YamlSequenceNode yaml)
205+
{
206+
return yaml.ToJsonArray(new YamlConversionBudget(MaxDepth, MaxNodeCount), 0);
207+
}
208+
209+
private static JsonArray ToJsonArray(this YamlSequenceNode yaml, YamlConversionBudget budget, uint depth)
106210
{
107211
var node = new JsonArray();
108212
foreach (var value in yaml)
109213
{
110-
node.Add(value.ToJsonNode());
214+
node.Add(value.ToJsonNode(budget, depth + 1));
111215
}
112216

113217
return node;

test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,32 @@ public void ReadThrowsWhenSettingsIsNull()
7575
Assert.Throws<ArgumentNullException>(() => reader.Read(stream, DocumentLocation, null!));
7676
}
7777

78+
[Fact]
79+
public void ReadReturnsDiagnosticErrorForExponentialAliasExpansion()
80+
{
81+
// A "billion laughs" YAML bomb must surface as a diagnostic error with no document,
82+
// rather than throwing or exhausting memory.
83+
var reader = new OpenApiYamlReader();
84+
using var stream = CreateStream(
85+
"""
86+
a: &a ["x","x","x","x","x","x","x","x","x"]
87+
b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]
88+
c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]
89+
d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]
90+
e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d]
91+
f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e]
92+
g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f]
93+
h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g]
94+
i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h]
95+
""");
96+
97+
var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings);
98+
99+
Assert.Null(result.Document);
100+
Assert.NotEmpty(result.Diagnostic.Errors);
101+
Assert.Equal(OpenApiConstants.Yaml, result.Diagnostic.Format);
102+
}
103+
78104
private static MemoryStream CreateStream(string yaml)
79105
{
80106
return new MemoryStream(Encoding.UTF8.GetBytes(yaml));

test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,99 @@ public void RoundTripEmptyStringsValues()
333333
Assert.Equal(yamlInput.MakeLineBreaksEnvironmentNeutral(), convertedBackOutput.MakeLineBreaksEnvironmentNeutral());
334334
}
335335

336+
[Fact]
337+
public void ExponentialAliasExpansionIsRejected()
338+
{
339+
// A "billion laughs" YAML bomb: each level references the previous one multiple times,
340+
// so materializing the shared node graph into an independent JSON tree expands
341+
// exponentially. The conversion must fail fast instead of exhausting memory.
342+
var yamlBomb =
343+
"""
344+
a: &a ["x","x","x","x","x","x","x","x","x"]
345+
b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]
346+
c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]
347+
d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]
348+
e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d]
349+
f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e]
350+
g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f]
351+
h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g]
352+
i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h]
353+
""";
354+
355+
Assert.Throws<OpenApiReaderException>(() => ConvertYamlStringToJsonNode(yamlBomb));
356+
}
357+
358+
[Fact]
359+
public void ExcessiveNestingDepthIsRejected()
360+
{
361+
// Deeper than the conversion depth limit (mirrors the System.Text.Json default of 64),
362+
// which protects the recursive converter from stack exhaustion.
363+
const int depth = 70;
364+
var deeplyNested = new string('[', depth) + new string(']', depth);
365+
366+
Assert.Throws<OpenApiReaderException>(() => ConvertYamlStringToJsonNode(deeplyNested));
367+
}
368+
369+
[Fact]
370+
public void LegitimateAliasesStillConvert()
371+
{
372+
var yamlInput =
373+
"""
374+
a: &val hello
375+
b: *val
376+
""";
377+
378+
var jsonNode = Assert.IsType<JsonObject>(ConvertYamlStringToJsonNode(yamlInput));
379+
380+
Assert.Equal("hello", jsonNode["a"]?.GetValue<string>());
381+
Assert.Equal("hello", jsonNode["b"]?.GetValue<string>());
382+
}
383+
384+
[Fact]
385+
public void ConversionLimitsDefaultToDocumentedValues()
386+
{
387+
Assert.Equal(64u, YamlConverter.DefaultMaxDepth);
388+
Assert.Equal(5_000_000u, YamlConverter.DefaultMaxNodeCount);
389+
Assert.Equal(YamlConverter.DefaultMaxDepth, YamlConverter.MaxDepth);
390+
Assert.Equal(YamlConverter.DefaultMaxNodeCount, YamlConverter.MaxNodeCount);
391+
}
392+
393+
[Fact]
394+
public void SettingMaxDepthToZeroThrows()
395+
{
396+
Assert.Throws<ArgumentOutOfRangeException>(() => YamlConverter.MaxDepth = 0);
397+
// The invalid assignment must not have changed the effective limit.
398+
Assert.Equal(YamlConverter.DefaultMaxDepth, YamlConverter.MaxDepth);
399+
}
400+
401+
[Fact]
402+
public void SettingMaxNodeCountToZeroThrows()
403+
{
404+
Assert.Throws<ArgumentOutOfRangeException>(() => YamlConverter.MaxNodeCount = 0);
405+
// The invalid assignment must not have changed the effective limit.
406+
Assert.Equal(YamlConverter.DefaultMaxNodeCount, YamlConverter.MaxNodeCount);
407+
}
408+
409+
[Fact]
410+
public void RaisingMaxDepthAllowsDocumentsDeeperThanTheDefault()
411+
{
412+
// A document nested deeper than the default depth limit (64) is rejected by default
413+
// but can be permitted by a consumer that opts into a higher limit.
414+
const int depth = 70;
415+
var deeplyNested = new string('[', depth) + new string(']', depth);
416+
417+
try
418+
{
419+
YamlConverter.MaxDepth = depth + 10;
420+
var jsonNode = ConvertYamlStringToJsonNode(deeplyNested);
421+
Assert.IsType<JsonArray>(jsonNode);
422+
}
423+
finally
424+
{
425+
YamlConverter.MaxDepth = YamlConverter.DefaultMaxDepth;
426+
}
427+
}
428+
336429
private static JsonNode ConvertYamlStringToJsonNode(string yamlInput)
337430
{
338431
var yamlDocument = new YamlStream();

0 commit comments

Comments
 (0)