Skip to content

Commit a361360

Browse files
authored
Merge pull request #3005 from microsoft/fix/oom-to-v2
fix: bound YAML anchor/alias expansion to prevent OOM (billion laughs) (#3000)
2 parents 4a9b082 + 4db9af0 commit a361360

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)