Skip to content

Commit 00c52aa

Browse files
Treicy Sanchez Gutierrez (from Dev Box)Copilot
andcommitted
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
1 parent 870fce0 commit 00c52aa

4 files changed

Lines changed: 150 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
}

src/Microsoft.OpenApi.YamlReader/YamlConverter.cs

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,51 @@ 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+
internal const int 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+
/// Increase this only if legitimate large documents are being rejected.
29+
/// </summary>
30+
internal const int DefaultMaxNodeCount = 5_000_000;
31+
32+
/// <summary>
33+
/// Tracks and enforces resource limits while converting a YAML node graph into JSON nodes,
34+
/// failing fast when a hostile document would otherwise exhaust memory or the stack.
35+
/// </summary>
36+
private sealed class YamlConversionBudget
37+
{
38+
private readonly int _maxDepth;
39+
private readonly int _maxNodeCount;
40+
private int _nodeCount;
41+
42+
public YamlConversionBudget(int maxDepth = DefaultMaxDepth, int maxNodeCount = DefaultMaxNodeCount)
43+
{
44+
_maxDepth = maxDepth;
45+
_maxNodeCount = maxNodeCount;
46+
}
47+
48+
public void EnterNode(int depth)
49+
{
50+
if (depth > _maxDepth)
51+
{
52+
throw new OpenApiReaderException($"The YAML document exceeds the maximum supported nesting depth of {_maxDepth}.");
53+
}
54+
55+
if (++_nodeCount > _maxNodeCount)
56+
{
57+
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.");
58+
}
59+
}
60+
}
61+
1762
/// <summary>
1863
/// Converts all of the documents in a YAML stream to <see cref="JsonNode"/>s.
1964
/// </summary>
@@ -42,10 +87,16 @@ public static JsonNode ToJsonNode(this YamlDocument yaml)
4287
/// <exception cref="NotSupportedException">Thrown for YAML that is not compatible with JSON.</exception>
4388
public static JsonNode ToJsonNode(this YamlNode yaml)
4489
{
90+
return yaml.ToJsonNode(new YamlConversionBudget(), 0);
91+
}
92+
93+
private static JsonNode ToJsonNode(this YamlNode yaml, YamlConversionBudget budget, int depth)
94+
{
95+
budget.EnterNode(depth);
4596
return yaml switch
4697
{
47-
YamlMappingNode map => map.ToJsonObject(),
48-
YamlSequenceNode seq => seq.ToJsonArray(),
98+
YamlMappingNode map => map.ToJsonObject(budget, depth),
99+
YamlSequenceNode seq => seq.ToJsonArray(budget, depth),
49100
YamlScalarNode scalar => scalar.ToJsonValue(),
50101
_ => throw new NotSupportedException("This yaml isn't convertible to JSON")
51102
};
@@ -78,12 +129,17 @@ public static YamlNode ToYamlNode(this JsonNode json)
78129
/// <param name="yaml"></param>
79130
/// <returns></returns>
80131
public static JsonObject ToJsonObject(this YamlMappingNode yaml)
132+
{
133+
return yaml.ToJsonObject(new YamlConversionBudget(), 0);
134+
}
135+
136+
private static JsonObject ToJsonObject(this YamlMappingNode yaml, YamlConversionBudget budget, int depth)
81137
{
82138
var node = new JsonObject();
83139
foreach (var keyValuePair in yaml)
84140
{
85141
var key = ((YamlScalarNode)keyValuePair.Key).Value!;
86-
node[key] = keyValuePair.Value.ToJsonNode();
142+
node[key] = keyValuePair.Value.ToJsonNode(budget, depth + 1);
87143
}
88144

89145
return node;
@@ -103,11 +159,16 @@ private static YamlMappingNode ToYamlMapping(this JsonObject obj)
103159
/// <param name="yaml"></param>
104160
/// <returns></returns>
105161
public static JsonArray ToJsonArray(this YamlSequenceNode yaml)
162+
{
163+
return yaml.ToJsonArray(new YamlConversionBudget(), 0);
164+
}
165+
166+
private static JsonArray ToJsonArray(this YamlSequenceNode yaml, YamlConversionBudget budget, int depth)
106167
{
107168
var node = new JsonArray();
108169
foreach (var value in yaml)
109170
{
110-
node.Add(value.ToJsonNode());
171+
node.Add(value.ToJsonNode(budget, depth + 1));
111172
}
112173

113174
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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,54 @@ 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+
336384
private static JsonNode ConvertYamlStringToJsonNode(string yamlInput)
337385
{
338386
var yamlDocument = new YamlStream();

0 commit comments

Comments
 (0)