Skip to content

Commit f814c72

Browse files
Copilotbaywet
andcommitted
refactor(readers): move YAML expansion limits into OpenApiReaderSettings
Co-authored-by: baywet <7905502+baywet@users.noreply.github.com>
1 parent 92725e6 commit f814c72

6 files changed

Lines changed: 95 additions & 100 deletions

File tree

src/Microsoft.OpenApi.Readers/OpenApiReaderLimits.cs

Lines changed: 0 additions & 72 deletions
This file was deleted.

src/Microsoft.OpenApi.Readers/OpenApiReaderSettings.cs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,64 @@ public enum ReferenceResolutionSetting
3636
/// </summary>
3737
public class OpenApiReaderSettings
3838
{
39+
/// <summary>
40+
/// Default maximum nesting depth allowed when materializing values from a YAML/JSON node graph.
41+
/// Mirrors the default System.Text.Json depth limit (64), protecting the recursive readers
42+
/// from stack exhaustion on deeply nested documents.
43+
/// </summary>
44+
public const uint DefaultMaxDepth = 64;
45+
46+
/// <summary>
47+
/// Default maximum number of nodes that may be materialized from a single document.
48+
/// Guards against YAML anchor/alias expansion ("billion laughs") attacks, where a tiny document
49+
/// expands exponentially when its shared node graph is materialized into an independent tree.
50+
/// </summary>
51+
public const uint DefaultMaxNodeCount = 5_000_000;
52+
53+
private uint _maxDepth = DefaultMaxDepth;
54+
private uint _maxNodeCount = DefaultMaxNodeCount;
55+
56+
/// <summary>
57+
/// Gets or sets the maximum nesting depth allowed when materializing values from a node graph.
58+
/// Defaults to <see cref="DefaultMaxDepth"/>. Raise this if legitimate deeply nested documents are
59+
/// being rejected, or lower it to fail faster when only shallow documents are expected.
60+
/// </summary>
61+
/// <exception cref="ArgumentOutOfRangeException">Thrown when set to zero.</exception>
62+
public uint MaxDepth
63+
{
64+
get => _maxDepth;
65+
set
66+
{
67+
if (value == 0)
68+
{
69+
throw new ArgumentOutOfRangeException(nameof(value), "MaxDepth must be greater than zero.");
70+
}
71+
72+
_maxDepth = value;
73+
}
74+
}
75+
76+
/// <summary>
77+
/// Gets or sets the maximum number of nodes that may be materialized from a single document.
78+
/// Defaults to <see cref="DefaultMaxNodeCount"/>, guarding against YAML anchor/alias expansion
79+
/// ("billion laughs") attacks. Raise this if legitimate large documents are being rejected, or lower
80+
/// it to fail faster when only small documents are expected.
81+
/// </summary>
82+
/// <exception cref="ArgumentOutOfRangeException">Thrown when set to zero.</exception>
83+
public uint MaxNodeCount
84+
{
85+
get => _maxNodeCount;
86+
set
87+
{
88+
if (value == 0)
89+
{
90+
throw new ArgumentOutOfRangeException(nameof(value), "MaxNodeCount must be greater than zero.");
91+
}
92+
93+
_maxNodeCount = value;
94+
}
95+
}
96+
3997
/// <summary>
4098
/// Indicates how references in the source document should be handled.
4199
/// </summary>

src/Microsoft.OpenApi.Readers/OpenApiYamlDocumentReader.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ public OpenApiDocument Read(YamlDocument input, out OpenApiDiagnostic diagnostic
4747
{
4848
ExtensionParsers = _settings.ExtensionParsers,
4949
BaseUrl = _settings.BaseUrl,
50-
DefaultContentType = _settings.DefaultContentType
50+
DefaultContentType = _settings.DefaultContentType,
51+
MaxDepth = _settings.MaxDepth,
52+
MaxNodeCount = _settings.MaxNodeCount
5153
};
5254

5355
OpenApiDocument document = null;
@@ -91,7 +93,9 @@ public async Task<ReadResult> ReadAsync(YamlDocument input, CancellationToken ca
9193
var context = new ParsingContext(diagnostic)
9294
{
9395
ExtensionParsers = _settings.ExtensionParsers,
94-
BaseUrl = _settings.BaseUrl
96+
BaseUrl = _settings.BaseUrl,
97+
MaxDepth = _settings.MaxDepth,
98+
MaxNodeCount = _settings.MaxNodeCount
9599
};
96100

97101
OpenApiDocument document = null;
@@ -184,7 +188,9 @@ public T ReadFragment<T>(YamlDocument input, OpenApiSpecVersion version, out Ope
184188
diagnostic = new();
185189
var context = new ParsingContext(diagnostic)
186190
{
187-
ExtensionParsers = _settings.ExtensionParsers
191+
ExtensionParsers = _settings.ExtensionParsers,
192+
MaxDepth = _settings.MaxDepth,
193+
MaxNodeCount = _settings.MaxNodeCount
188194
};
189195

190196
IOpenApiElement element = null;

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ public IOpenApiAny CreateAny()
8282
/// <summary>
8383
/// Materializes the node, and everything below it, into an <see cref="IOpenApiAny"/>.
8484
/// </summary>
85-
/// <param name="depth">Nesting depth of the current node, bounded by <see cref="OpenApiReaderLimits.MaxDepth"/>.</param>
85+
/// <param name="depth">Nesting depth of the current node, bounded by <see cref="OpenApiReaderSettings.MaxDepth"/>.</param>
8686
internal virtual IOpenApiAny CreateAny(uint depth)
8787
{
8888
throw new OpenApiReaderException("Cannot create an Any object this type of node.", Context);
@@ -94,9 +94,10 @@ internal virtual IOpenApiAny CreateAny(uint depth)
9494
/// </summary>
9595
protected void EnsureDepthWithinLimit(uint depth)
9696
{
97-
if (depth > OpenApiReaderLimits.MaxDepth)
97+
var maxDepth = Context?.MaxDepth ?? OpenApiReaderSettings.DefaultMaxDepth;
98+
if (depth > maxDepth)
9899
{
99-
throw new OpenApiReaderException($"The document exceeds the maximum supported nesting depth of {OpenApiReaderLimits.MaxDepth}.", Context);
100+
throw new OpenApiReaderException($"The document exceeds the maximum supported nesting depth of {maxDepth}.", Context);
100101
}
101102
}
102103

src/Microsoft.OpenApi.Readers/ParsingContext.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ public class ParsingContext
2626
private readonly Dictionary<object, Dictionary<string, object>> _scopedTempStorage = new();
2727
private readonly Dictionary<string, Stack<string>> _loopStacks = new();
2828
private uint _nodeCount;
29+
internal uint MaxDepth { get; set; } = OpenApiReaderSettings.DefaultMaxDepth;
30+
internal uint MaxNodeCount { get; set; } = OpenApiReaderSettings.DefaultMaxNodeCount;
2931
internal Dictionary<string, Func<IOpenApiAny, OpenApiSpecVersion, IOpenApiExtension>> ExtensionParsers { get; set; } = new();
3032
internal RootNode RootNode { get; set; }
3133
internal List<OpenApiTag> Tags { get; private set; } = new();
@@ -201,15 +203,15 @@ public void StartObject(string objectName)
201203

202204
/// <summary>
203205
/// 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
206+
/// document expands beyond <see cref="OpenApiReaderSettings.MaxNodeCount"/>. YAML anchors and
205207
/// aliases share a single node in the source graph, so a tiny document can expand
206208
/// exponentially ("billion laughs") when it is materialized into an independent tree.
207209
/// </summary>
208210
internal void CountNode()
209211
{
210-
if (++_nodeCount > OpenApiReaderLimits.MaxNodeCount)
212+
if (++_nodeCount > MaxNodeCount)
211213
{
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.");
214+
throw new OpenApiReaderException($"The document expands to more than the maximum supported number of nodes ({MaxNodeCount}). This may indicate a YAML anchor/alias expansion (billion laughs) attack.");
213215
}
214216
}
215217

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

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) Microsoft Corporation. All rights reserved.
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT license.
33

44
using System;
@@ -92,26 +92,32 @@ public void LegitimateAliasesStillConvert()
9292
[Fact]
9393
public void ConversionLimitsDefaultToDocumentedValues()
9494
{
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);
95+
var settings = new OpenApiReaderSettings();
96+
97+
Assert.Equal(64u, OpenApiReaderSettings.DefaultMaxDepth);
98+
Assert.Equal(5_000_000u, OpenApiReaderSettings.DefaultMaxNodeCount);
99+
Assert.Equal(OpenApiReaderSettings.DefaultMaxDepth, settings.MaxDepth);
100+
Assert.Equal(OpenApiReaderSettings.DefaultMaxNodeCount, settings.MaxNodeCount);
99101
}
100102

101103
[Fact]
102104
public void SettingMaxDepthToZeroThrows()
103105
{
104-
Assert.Throws<ArgumentOutOfRangeException>(() => OpenApiReaderLimits.MaxDepth = 0);
106+
var settings = new OpenApiReaderSettings();
107+
108+
Assert.Throws<ArgumentOutOfRangeException>(() => settings.MaxDepth = 0);
105109
// The invalid assignment must not have changed the effective limit.
106-
Assert.Equal(OpenApiReaderLimits.DefaultMaxDepth, OpenApiReaderLimits.MaxDepth);
110+
Assert.Equal(OpenApiReaderSettings.DefaultMaxDepth, settings.MaxDepth);
107111
}
108112

109113
[Fact]
110114
public void SettingMaxNodeCountToZeroThrows()
111115
{
112-
Assert.Throws<ArgumentOutOfRangeException>(() => OpenApiReaderLimits.MaxNodeCount = 0);
116+
var settings = new OpenApiReaderSettings();
117+
118+
Assert.Throws<ArgumentOutOfRangeException>(() => settings.MaxNodeCount = 0);
113119
// The invalid assignment must not have changed the effective limit.
114-
Assert.Equal(OpenApiReaderLimits.DefaultMaxNodeCount, OpenApiReaderLimits.MaxNodeCount);
120+
Assert.Equal(OpenApiReaderSettings.DefaultMaxNodeCount, settings.MaxNodeCount);
115121
}
116122

117123
[Fact]
@@ -122,16 +128,10 @@ public void RaisingMaxDepthAllowsDocumentsDeeperThanTheDefault()
122128
const int depth = 70;
123129
var deeplyNested = new string('[', depth) + new string(']', depth);
124130

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-
}
131+
var context = new ParsingContext(new()) { MaxDepth = depth + 10 };
132+
var node = ParseNode.Create(context, YamlHelper.ParseYamlString(deeplyNested));
133+
134+
Assert.IsType<OpenApiArray>(node.CreateAny());
135135
}
136136

137137
private static string YamlBombIndented()

0 commit comments

Comments
 (0)