Skip to content

Commit 4db9af0

Browse files
TreicysgTreicy Sanchez Gutierrez (from Dev Box)Copilot
authored andcommitted
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 100804c commit 4db9af0

5 files changed

Lines changed: 355 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;
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
using System;
2+
using System.IO;
3+
using System.Text;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using Microsoft.OpenApi.Reader;
7+
using Microsoft.OpenApi.YamlReader;
8+
using Xunit;
9+
10+
namespace Microsoft.OpenApi.Readers.Tests;
11+
12+
public class OpenApiYamlReaderTests
13+
{
14+
private static readonly Uri DocumentLocation = new("https://contoso.test/openapi.yaml");
15+
16+
[Fact]
17+
public async Task ReadAsyncParsesDocumentsFromNonMemoryStreams()
18+
{
19+
var reader = new OpenApiYamlReader();
20+
await using var stream = new NonMemoryStream(CreateStream(
21+
"""
22+
openapi: 3.0.1
23+
info:
24+
title: Sample API
25+
version: 1.0.0
26+
paths: {}
27+
"""));
28+
29+
var result = await reader.ReadAsync(stream, DocumentLocation, SettingsFixture.ReaderSettings, CancellationToken.None);
30+
31+
Assert.NotNull(result.Document);
32+
Assert.Equal("Sample API", result.Document.Info.Title);
33+
Assert.Equal(OpenApiConstants.Yaml, result.Diagnostic.Format);
34+
}
35+
36+
[Fact]
37+
public void ReadThrowsWhenYamlDoesNotContainADocument()
38+
{
39+
var reader = new OpenApiYamlReader();
40+
using var stream = CreateStream(string.Empty);
41+
42+
var exception = Assert.Throws<InvalidOperationException>(() => reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings));
43+
44+
Assert.Equal("No documents found in the YAML stream.", exception.Message);
45+
}
46+
47+
[Fact]
48+
public void ReadFragmentParsesSchemaFragments()
49+
{
50+
var reader = new OpenApiYamlReader();
51+
using var stream = CreateStream(
52+
"""
53+
type: string
54+
description: A reusable schema
55+
""");
56+
57+
var schema = reader.ReadFragment<OpenApiSchema>(
58+
stream,
59+
OpenApiSpecVersion.OpenApi3_0,
60+
new OpenApiDocument(),
61+
out var diagnostic);
62+
63+
Assert.NotNull(schema);
64+
Assert.Empty(diagnostic.Errors);
65+
Assert.Equal(JsonSchemaType.String, schema.Type);
66+
Assert.Equal("A reusable schema", schema.Description);
67+
}
68+
69+
[Fact]
70+
public void ReadThrowsWhenSettingsIsNull()
71+
{
72+
var reader = new OpenApiYamlReader();
73+
using var stream = CreateStream("openapi: 3.0.1");
74+
75+
Assert.Throws<ArgumentNullException>(() => reader.Read(stream, DocumentLocation, null!));
76+
}
77+
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+
104+
private static MemoryStream CreateStream(string yaml)
105+
{
106+
return new MemoryStream(Encoding.UTF8.GetBytes(yaml));
107+
}
108+
109+
private sealed class NonMemoryStream(Stream innerStream) : Stream
110+
{
111+
public override bool CanRead => innerStream.CanRead;
112+
public override bool CanSeek => innerStream.CanSeek;
113+
public override bool CanWrite => innerStream.CanWrite;
114+
public override long Length => innerStream.Length;
115+
public override long Position
116+
{
117+
get => innerStream.Position;
118+
set => innerStream.Position = value;
119+
}
120+
121+
public override void Flush() => innerStream.Flush();
122+
public override int Read(byte[] buffer, int offset, int count) => innerStream.Read(buffer, offset, count);
123+
public override long Seek(long offset, SeekOrigin origin) => innerStream.Seek(offset, origin);
124+
public override void SetLength(long value) => innerStream.SetLength(value);
125+
public override void Write(byte[] buffer, int offset, int count) => innerStream.Write(buffer, offset, count);
126+
public override ValueTask DisposeAsync() => innerStream.DisposeAsync();
127+
protected override void Dispose(bool disposing)
128+
{
129+
if (disposing)
130+
{
131+
innerStream.Dispose();
132+
}
133+
134+
base.Dispose(disposing);
135+
}
136+
}
137+
}

0 commit comments

Comments
 (0)