Skip to content

Commit 79ed241

Browse files
committed
fix: harden yaml parsing
1 parent 3f012bc commit 79ed241

16 files changed

Lines changed: 1644 additions & 176 deletions

src/Microsoft.OpenApi.YamlReader/Microsoft.OpenApi.YamlReader.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
<PrivateAssets>all</PrivateAssets>
3939
</PackageReference>
4040

41-
<PackageReference Include="SharpYaml" Version="2.1.4" />
41+
<PackageReference Include="SharpYaml" Version="2.1.5" />
4242
<PackageReference Include="System.Text.Json" Version="[8.0.5,)" />
4343
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-hh2w-p6rv-4g7w" />
4444
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-8g4q-xg66-9fp4" />

src/Microsoft.OpenApi.YamlReader/OpenApiReaderSettingsExtensions.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using Microsoft.OpenApi.YamlReader;
1+
using System;
2+
using Microsoft.OpenApi.YamlReader;
23

34
namespace Microsoft.OpenApi.Reader;
45

@@ -17,4 +18,18 @@ public static void AddYamlReader(this OpenApiReaderSettings settings)
1718
settings.TryAddReader(OpenApiConstants.Yaml, yamlReader);
1819
settings.TryAddReader(OpenApiConstants.Yml, yamlReader);
1920
}
21+
22+
/// <summary>
23+
/// Adds a YAML reader for the specified format using per-reader resource limits.
24+
/// </summary>
25+
/// <param name="settings">The settings to add the reader to.</param>
26+
/// <param name="yamlSettings">The YAML reader settings.</param>
27+
public static void AddYamlReader(this OpenApiReaderSettings settings, OpenApiYamlReaderSettings yamlSettings)
28+
{
29+
if (settings is null) throw new ArgumentNullException(nameof(settings));
30+
if (yamlSettings is null) throw new ArgumentNullException(nameof(yamlSettings));
31+
var yamlReader = new OpenApiYamlReader(yamlSettings);
32+
settings.TryAddReader(OpenApiConstants.Yaml, yamlReader);
33+
settings.TryAddReader(OpenApiConstants.Yml, yamlReader);
34+
}
2035
}

src/Microsoft.OpenApi.YamlReader/OpenApiYamlReader.cs

Lines changed: 141 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,55 @@
77
using System.Threading;
88
using System.Threading.Tasks;
99
using Microsoft.OpenApi.Reader;
10-
using SharpYaml.Serialization;
10+
using SharpYaml;
1111
using System;
12-
using System.Linq;
1312
using System.Text;
1413

1514
namespace Microsoft.OpenApi.YamlReader
1615
{
1716
/// <summary>
1817
/// Reader for parsing YAML files into an OpenAPI document.
1918
/// </summary>
19+
/// <remarks>
20+
/// Input is converted directly from SharpYaml parser events so resource limits are enforced
21+
/// before SharpYaml's recursive YAML model loader can compose or expand the document.
22+
/// </remarks>
2023
public class OpenApiYamlReader : IOpenApiReader
2124
{
2225
private const int copyBufferSize = 4096;
2326
private static readonly OpenApiJsonReader _jsonReader = new();
27+
private readonly OpenApiYamlReaderSettings _yamlSettings;
28+
29+
/// <summary>
30+
/// Initializes a YAML reader using the current legacy global conversion limits.
31+
/// </summary>
32+
public OpenApiYamlReader()
33+
: this(new()
34+
{
35+
MaxDepth = YamlConverter.MaxDepth,
36+
MaxNodeCount = YamlConverter.MaxNodeCount,
37+
MaxAliasExpansionNodeCount = YamlConverter.MaxAliasExpansionNodeCount,
38+
})
39+
{
40+
}
41+
42+
/// <summary>
43+
/// Initializes a YAML reader with immutable per-reader resource limits.
44+
/// </summary>
45+
/// <param name="settings">The YAML reader settings.</param>
46+
public OpenApiYamlReader(OpenApiYamlReaderSettings settings)
47+
{
48+
if (settings is null) throw new ArgumentNullException(nameof(settings));
49+
settings.Validate();
50+
_yamlSettings = new()
51+
{
52+
MaxDepth = settings.MaxDepth,
53+
MaxNodeCount = settings.MaxNodeCount,
54+
MaxAliasExpansionNodeCount = settings.MaxAliasExpansionNodeCount,
55+
MaxInputByteCount = settings.MaxInputByteCount,
56+
MaxScalarLength = settings.MaxScalarLength,
57+
};
58+
}
2459

2560
/// <inheritdoc/>
2661
public async Task<ReadResult> ReadAsync(Stream input,
@@ -29,65 +64,122 @@ public async Task<ReadResult> ReadAsync(Stream input,
2964
CancellationToken cancellationToken = default)
3065
{
3166
if (input is null) throw new ArgumentNullException(nameof(input));
67+
if (settings is null) throw new ArgumentNullException(nameof(settings));
3268
if (input is MemoryStream memoryStream)
3369
{
34-
return UpdateFormat(Read(memoryStream, location, settings));
70+
return ReadCore(memoryStream, location, settings, cancellationToken);
3571
}
3672
else
3773
{
3874
using var preparedStream = new MemoryStream();
39-
await input.CopyToAsync(preparedStream, copyBufferSize, cancellationToken).ConfigureAwait(false);
75+
try
76+
{
77+
await CopyToMemoryStreamAsync(
78+
input,
79+
preparedStream,
80+
_yamlSettings.MaxInputByteCount,
81+
cancellationToken).ConfigureAwait(false);
82+
}
83+
catch (OpenApiReaderException ex)
84+
{
85+
return new()
86+
{
87+
Document = null,
88+
Diagnostic = CreateDiagnostic(new(ex)),
89+
};
90+
}
91+
4092
preparedStream.Position = 0;
41-
return UpdateFormat(Read(preparedStream, location, settings));
93+
return ReadCore(preparedStream, location, settings, cancellationToken);
4294
}
4395
}
4496

4597
/// <inheritdoc/>
4698
public ReadResult Read(MemoryStream input,
4799
Uri location,
48100
OpenApiReaderSettings settings)
101+
=> ReadCore(input, location, settings, CancellationToken.None);
102+
103+
private ReadResult ReadCore(MemoryStream input,
104+
Uri location,
105+
OpenApiReaderSettings settings,
106+
CancellationToken cancellationToken)
49107
{
50108
if (input is null) throw new ArgumentNullException(nameof(input));
51109
if (settings is null) throw new ArgumentNullException(nameof(settings));
110+
cancellationToken.ThrowIfCancellationRequested();
52111
JsonNode jsonNode;
53112

54113
// Parse the YAML text in the stream into a sequence of JsonNodes
55114
try
56115
{
116+
EnsureInputWithinLimit(input, _yamlSettings.MaxInputByteCount);
57117
#if NET
58118
// this represents net core, net5 and up
59119
using var stream = new StreamReader(input, default, true, -1, settings.LeaveStreamOpen);
60120
#else
61121
// the implementation differs and results in a null reference exception in NETFX
62122
using var stream = new StreamReader(input, Encoding.UTF8, true, 4096, settings.LeaveStreamOpen);
63123
#endif
64-
jsonNode = LoadJsonNodesFromYamlDocument(stream);
124+
jsonNode = LoadJsonNodesFromYamlDocument(stream, cancellationToken);
65125
}
66126
catch (JsonException ex)
67127
{
68-
var diagnostic = new OpenApiDiagnostic();
69-
diagnostic.Errors.Add(new($"#line={ex.LineNumber}", ex.Message));
70-
diagnostic.Format = OpenApiConstants.Yaml;
71128
return new()
72129
{
73130
Document = null,
74-
Diagnostic = diagnostic,
131+
Diagnostic = CreateDiagnostic(new($"#line={ex.LineNumber}", ex.Message)),
75132
};
76133
}
77134
catch (OpenApiReaderException ex)
78135
{
79-
var diagnostic = new OpenApiDiagnostic();
80-
diagnostic.Errors.Add(new(ex));
81-
diagnostic.Format = OpenApiConstants.Yaml;
82136
return new()
83137
{
84138
Document = null,
85-
Diagnostic = diagnostic,
139+
Diagnostic = CreateDiagnostic(new(ex)),
86140
};
87141
}
88142

143+
cancellationToken.ThrowIfCancellationRequested();
89144
return UpdateFormat(Read(jsonNode, location, settings));
90145
}
146+
147+
private static async Task CopyToMemoryStreamAsync(
148+
Stream input,
149+
MemoryStream output,
150+
uint maxInputByteCount,
151+
CancellationToken cancellationToken)
152+
{
153+
var buffer = new byte[copyBufferSize];
154+
long totalBytesRead = 0;
155+
int bytesRead;
156+
while ((bytesRead = await input.ReadAsync(
157+
buffer,
158+
0,
159+
buffer.Length,
160+
cancellationToken).ConfigureAwait(false)) > 0)
161+
{
162+
if (bytesRead > (long)maxInputByteCount - totalBytesRead)
163+
{
164+
throw CreateInputLimitException(maxInputByteCount);
165+
}
166+
167+
await output.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false);
168+
totalBytesRead += bytesRead;
169+
}
170+
}
171+
172+
private static void EnsureInputWithinLimit(MemoryStream input, uint maxInputByteCount)
173+
{
174+
if (input.Length - input.Position > maxInputByteCount)
175+
{
176+
throw CreateInputLimitException(maxInputByteCount);
177+
}
178+
}
179+
180+
private static OpenApiReaderException CreateInputLimitException(uint maxInputByteCount)
181+
=> new($"The YAML input exceeds the maximum supported size of {maxInputByteCount} bytes.");
182+
91183
private static ReadResult UpdateFormat(ReadResult result)
92184
{
93185
result.Diagnostic ??= new OpenApiDiagnostic();
@@ -114,13 +206,22 @@ public static ReadResult Read(JsonNode jsonNode, Uri location, OpenApiReaderSett
114206
// Parse the YAML
115207
try
116208
{
117-
using var stream = new StreamReader(input);
118-
jsonNode = LoadJsonNodesFromYamlDocument(stream);
209+
EnsureInputWithinLimit(input, _yamlSettings.MaxInputByteCount);
210+
#if NET
211+
using var stream = new StreamReader(input, default, true, -1, settings?.LeaveStreamOpen ?? false);
212+
#else
213+
using var stream = new StreamReader(input, Encoding.UTF8, true, 4096, settings?.LeaveStreamOpen ?? false);
214+
#endif
215+
jsonNode = LoadJsonNodesFromYamlDocument(stream, CancellationToken.None);
119216
}
120217
catch (JsonException ex)
121218
{
122-
diagnostic = new();
123-
diagnostic.Errors.Add(new($"#line={ex.LineNumber}", ex.Message));
219+
diagnostic = CreateDiagnostic(new($"#line={ex.LineNumber}", ex.Message));
220+
return default;
221+
}
222+
catch (OpenApiReaderException ex)
223+
{
224+
diagnostic = CreateDiagnostic(new(ex));
124225
return default;
125226
}
126227

@@ -134,20 +235,34 @@ public static ReadResult Read(JsonNode jsonNode, Uri location, OpenApiReaderSett
134235
}
135236

136237
/// <summary>
137-
/// Helper method to turn streams into a sequence of JsonNodes
238+
/// Converts the first YAML document in a stream into a JSON node.
138239
/// </summary>
139240
/// <param name="input">Stream containing YAML formatted text</param>
140-
/// <returns>Instance of a YamlDocument</returns>
141-
static JsonNode LoadJsonNodesFromYamlDocument(TextReader input)
241+
/// <param name="cancellationToken">Propagates notification that parsing should be cancelled.</param>
242+
/// <returns>The converted JSON node.</returns>
243+
private JsonNode LoadJsonNodesFromYamlDocument(TextReader input, CancellationToken cancellationToken)
142244
{
143-
var yamlStream = new YamlStream();
144-
yamlStream.Load(input);
145-
if (yamlStream.Documents.Any() && yamlStream.Documents[0].ToJsonNode() is { } jsonNode)
245+
try
146246
{
147-
return jsonNode;
247+
return new YamlJsonParser(_yamlSettings).Parse(input, cancellationToken);
148248
}
249+
catch (YamlException ex)
250+
{
251+
var location = ex.Start.Line >= 0
252+
? $" at line {ex.Start.Line + 1}, column {ex.Start.Column + 1}"
253+
: string.Empty;
254+
throw new OpenApiReaderException($"Unable to parse the YAML document{location}: {ex.Message}", ex);
255+
}
256+
}
149257

150-
throw new InvalidOperationException("No documents found in the YAML stream.");
258+
private static OpenApiDiagnostic CreateDiagnostic(OpenApiError error)
259+
{
260+
var diagnostic = new OpenApiDiagnostic
261+
{
262+
Format = OpenApiConstants.Yaml,
263+
};
264+
diagnostic.Errors.Add(error);
265+
return diagnostic;
151266
}
152267
}
153268
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
using System;
2+
3+
namespace Microsoft.OpenApi.YamlReader;
4+
5+
/// <summary>
6+
/// Configures resource limits for an <see cref="OpenApiYamlReader"/>.
7+
/// </summary>
8+
public sealed class OpenApiYamlReaderSettings
9+
{
10+
/// <summary>
11+
/// Default maximum number of input bytes read from a single YAML document (128 MiB).
12+
/// Bounds the buffered copy of a non-seekable stream, so an endless or oversized response body
13+
/// cannot exhaust memory before parsing begins.
14+
/// </summary>
15+
public const uint DefaultMaxInputByteCount = 128 * 1024 * 1024;
16+
17+
/// <summary>
18+
/// Default maximum length of a single YAML scalar value (65,536 UTF-16 code units).
19+
/// Bounds the cost of any one key, string, number, date or block literal. For reference, the
20+
/// longest scalar in the Microsoft Graph beta description is 1,833 code units, so this leaves
21+
/// substantial headroom for legitimate documents.
22+
/// </summary>
23+
public const uint DefaultMaxScalarLength = 64 * 1024;
24+
25+
/// <summary>
26+
/// Gets or sets the maximum YAML nesting depth.
27+
/// Defaults to <see cref="YamlConverter.DefaultMaxDepth"/> and cannot exceed
28+
/// <see cref="YamlConverter.MaximumAllowedDepth"/>.
29+
/// </summary>
30+
public uint MaxDepth { get; set; } = YamlConverter.DefaultMaxDepth;
31+
32+
/// <summary>
33+
/// Gets or sets the maximum number of JSON nodes materialized from one YAML document.
34+
/// Defaults to <see cref="YamlConverter.DefaultMaxNodeCount"/> and cannot exceed
35+
/// <see cref="YamlConverter.MaximumAllowedNodeCount"/>.
36+
/// </summary>
37+
public uint MaxNodeCount { get; set; } = YamlConverter.DefaultMaxNodeCount;
38+
39+
/// <summary>
40+
/// Gets or sets the maximum number of JSON nodes materialized specifically from aliases.
41+
/// Defaults to <see cref="YamlConverter.DefaultMaxAliasExpansionNodeCount"/>.
42+
/// </summary>
43+
public uint MaxAliasExpansionNodeCount { get; set; } = YamlConverter.DefaultMaxAliasExpansionNodeCount;
44+
45+
/// <summary>
46+
/// Gets or sets the maximum number of input bytes read from one YAML document.
47+
/// Defaults to <see cref="DefaultMaxInputByteCount"/>.
48+
/// </summary>
49+
public uint MaxInputByteCount { get; set; } = DefaultMaxInputByteCount;
50+
51+
/// <summary>
52+
/// Gets or sets the maximum length of one YAML scalar value.
53+
/// Defaults to <see cref="DefaultMaxScalarLength"/>.
54+
/// </summary>
55+
public uint MaxScalarLength { get; set; } = DefaultMaxScalarLength;
56+
57+
internal void Validate()
58+
{
59+
YamlConverter.ValidateMaxDepth(MaxDepth, nameof(MaxDepth));
60+
YamlConverter.ValidateMaxNodeCount(MaxNodeCount, nameof(MaxNodeCount));
61+
ValidatePositive(MaxAliasExpansionNodeCount, nameof(MaxAliasExpansionNodeCount));
62+
ValidatePositive(MaxInputByteCount, nameof(MaxInputByteCount));
63+
ValidatePositive(MaxScalarLength, nameof(MaxScalarLength));
64+
}
65+
66+
private static void ValidatePositive(uint value, string parameterName)
67+
{
68+
if (value == 0)
69+
{
70+
throw new ArgumentOutOfRangeException(parameterName, $"{parameterName} must be greater than zero.");
71+
}
72+
}
73+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,22 @@
11
#nullable enable
2+
Microsoft.OpenApi.YamlReader.OpenApiYamlReader.OpenApiYamlReader(Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings! settings) -> void
3+
Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings
4+
Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxAliasExpansionNodeCount.get -> uint
5+
Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxAliasExpansionNodeCount.set -> void
6+
Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxDepth.get -> uint
7+
Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxDepth.set -> void
8+
Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxInputByteCount.get -> uint
9+
Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxInputByteCount.set -> void
10+
Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxNodeCount.get -> uint
11+
Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxNodeCount.set -> void
12+
Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxScalarLength.get -> uint
13+
Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.MaxScalarLength.set -> void
14+
Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.OpenApiYamlReaderSettings() -> void
15+
const Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.DefaultMaxInputByteCount = 134217728 -> uint
16+
const Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings.DefaultMaxScalarLength = 65536 -> uint
17+
const Microsoft.OpenApi.YamlReader.YamlConverter.DefaultMaxAliasExpansionNodeCount = 5000 -> uint
18+
const Microsoft.OpenApi.YamlReader.YamlConverter.MaximumAllowedDepth = 256 -> uint
19+
const Microsoft.OpenApi.YamlReader.YamlConverter.MaximumAllowedNodeCount = 10000000 -> uint
20+
static Microsoft.OpenApi.Reader.OpenApiReaderSettingsExtensions.AddYamlReader(this Microsoft.OpenApi.Reader.OpenApiReaderSettings! settings, Microsoft.OpenApi.YamlReader.OpenApiYamlReaderSettings! yamlSettings) -> void
21+
static Microsoft.OpenApi.YamlReader.YamlConverter.MaxAliasExpansionNodeCount.get -> uint
22+
static Microsoft.OpenApi.YamlReader.YamlConverter.MaxAliasExpansionNodeCount.set -> void

0 commit comments

Comments
 (0)