diff --git a/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReader.cs b/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReader.cs
index 0bf2627ec..cea996152 100644
--- a/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReader.cs
+++ b/src/Microsoft.OpenApi.YamlReader/OpenApiYamlReader.cs
@@ -74,6 +74,17 @@ public ReadResult Read(MemoryStream input,
Diagnostic = diagnostic,
};
}
+ catch (OpenApiReaderException ex)
+ {
+ var diagnostic = new OpenApiDiagnostic();
+ diagnostic.Errors.Add(new(ex));
+ diagnostic.Format = OpenApiConstants.Yaml;
+ return new()
+ {
+ Document = null,
+ Diagnostic = diagnostic,
+ };
+ }
return UpdateFormat(Read(jsonNode, location, settings));
}
diff --git a/src/Microsoft.OpenApi.YamlReader/PublicAPI.Unshipped.txt b/src/Microsoft.OpenApi.YamlReader/PublicAPI.Unshipped.txt
index 7dc5c5811..0fc6eaad5 100644
--- a/src/Microsoft.OpenApi.YamlReader/PublicAPI.Unshipped.txt
+++ b/src/Microsoft.OpenApi.YamlReader/PublicAPI.Unshipped.txt
@@ -1 +1,7 @@
#nullable enable
+const Microsoft.OpenApi.YamlReader.YamlConverter.DefaultMaxDepth = 64 -> int
+const Microsoft.OpenApi.YamlReader.YamlConverter.DefaultMaxNodeCount = 5000000 -> int
+static Microsoft.OpenApi.YamlReader.YamlConverter.MaxDepth.get -> int
+static Microsoft.OpenApi.YamlReader.YamlConverter.MaxDepth.set -> void
+static Microsoft.OpenApi.YamlReader.YamlConverter.MaxNodeCount.get -> int
+static Microsoft.OpenApi.YamlReader.YamlConverter.MaxNodeCount.set -> void
diff --git a/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs b/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs
index 1cafea77b..fbd4f5d18 100644
--- a/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs
+++ b/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs
@@ -14,6 +14,94 @@ namespace Microsoft.OpenApi.YamlReader
///
public static class YamlConverter
{
+ ///
+ /// Default maximum nesting depth allowed when converting a YAML node graph into JSON nodes.
+ /// Mirrors the default System.Text.Json depth limit (64) that already bounds the JSON reader path,
+ /// protecting the recursive conversion from stack exhaustion on deeply nested documents.
+ ///
+ public const int DefaultMaxDepth = 64;
+
+ ///
+ /// Default maximum number of JSON nodes that may be materialized from a single YAML document.
+ /// Guards against YAML anchor/alias expansion ("billion laughs") attacks, where a tiny document
+ /// expands exponentially when its shared node graph is materialized into an independent JSON tree.
+ ///
+ public const int DefaultMaxNodeCount = 5_000_000;
+
+ private static int _maxDepth = DefaultMaxDepth;
+ private static int _maxNodeCount = DefaultMaxNodeCount;
+
+ ///
+ /// Gets or sets the maximum nesting depth allowed when converting a YAML node graph into JSON nodes.
+ /// Defaults to . Raise this if legitimate deeply nested documents are
+ /// being rejected, or lower it to fail faster when only shallow documents are expected.
+ ///
+ /// Thrown when set to a value less than 1.
+ public static int MaxDepth
+ {
+ get => _maxDepth;
+ set
+ {
+ if (value < 1)
+ {
+ throw new ArgumentOutOfRangeException(nameof(value), "MaxDepth must be greater than zero.");
+ }
+
+ _maxDepth = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the maximum number of JSON nodes that may be materialized from a single YAML document.
+ /// Defaults to , guarding against YAML anchor/alias expansion
+ /// ("billion laughs") attacks. Raise this if legitimate large documents are being rejected, or lower
+ /// it to fail faster when only small documents are expected.
+ ///
+ /// Thrown when set to a value less than 1.
+ public static int MaxNodeCount
+ {
+ get => _maxNodeCount;
+ set
+ {
+ if (value < 1)
+ {
+ throw new ArgumentOutOfRangeException(nameof(value), "MaxNodeCount must be greater than zero.");
+ }
+
+ _maxNodeCount = value;
+ }
+ }
+
+ ///
+ /// Tracks and enforces resource limits while converting a YAML node graph into JSON nodes,
+ /// failing fast when a hostile document would otherwise exhaust memory or the stack.
+ ///
+ private sealed class YamlConversionBudget
+ {
+ private readonly int _maxDepth;
+ private readonly int _maxNodeCount;
+ private int _nodeCount;
+
+ public YamlConversionBudget(int maxDepth, int maxNodeCount)
+ {
+ _maxDepth = maxDepth;
+ _maxNodeCount = maxNodeCount;
+ }
+
+ public void EnterNode(int depth)
+ {
+ if (depth > _maxDepth)
+ {
+ throw new OpenApiReaderException($"The YAML document exceeds the maximum supported nesting depth of {_maxDepth}.");
+ }
+
+ if (++_nodeCount > _maxNodeCount)
+ {
+ 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.");
+ }
+ }
+ }
+
///
/// Converts all of the documents in a YAML stream to s.
///
@@ -42,10 +130,16 @@ public static JsonNode ToJsonNode(this YamlDocument yaml)
/// Thrown for YAML that is not compatible with JSON.
public static JsonNode ToJsonNode(this YamlNode yaml)
{
+ return yaml.ToJsonNode(new YamlConversionBudget(MaxDepth, MaxNodeCount), 0);
+ }
+
+ private static JsonNode ToJsonNode(this YamlNode yaml, YamlConversionBudget budget, int depth)
+ {
+ budget.EnterNode(depth);
return yaml switch
{
- YamlMappingNode map => map.ToJsonObject(),
- YamlSequenceNode seq => seq.ToJsonArray(),
+ YamlMappingNode map => map.ToJsonObject(budget, depth),
+ YamlSequenceNode seq => seq.ToJsonArray(budget, depth),
YamlScalarNode scalar => scalar.ToJsonValue(),
_ => throw new NotSupportedException("This yaml isn't convertible to JSON")
};
@@ -78,12 +172,17 @@ public static YamlNode ToYamlNode(this JsonNode json)
///
///
public static JsonObject ToJsonObject(this YamlMappingNode yaml)
+ {
+ return yaml.ToJsonObject(new YamlConversionBudget(MaxDepth, MaxNodeCount), 0);
+ }
+
+ private static JsonObject ToJsonObject(this YamlMappingNode yaml, YamlConversionBudget budget, int depth)
{
var node = new JsonObject();
foreach (var keyValuePair in yaml)
{
var key = ((YamlScalarNode)keyValuePair.Key).Value!;
- node[key] = keyValuePair.Value.ToJsonNode();
+ node[key] = keyValuePair.Value.ToJsonNode(budget, depth + 1);
}
return node;
@@ -103,11 +202,16 @@ private static YamlMappingNode ToYamlMapping(this JsonObject obj)
///
///
public static JsonArray ToJsonArray(this YamlSequenceNode yaml)
+ {
+ return yaml.ToJsonArray(new YamlConversionBudget(MaxDepth, MaxNodeCount), 0);
+ }
+
+ private static JsonArray ToJsonArray(this YamlSequenceNode yaml, YamlConversionBudget budget, int depth)
{
var node = new JsonArray();
foreach (var value in yaml)
{
- node.Add(value.ToJsonNode());
+ node.Add(value.ToJsonNode(budget, depth + 1));
}
return node;
diff --git a/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs b/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs
index 6d66430da..ea0ef0fd9 100644
--- a/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs
+++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs
@@ -75,6 +75,32 @@ public void ReadThrowsWhenSettingsIsNull()
Assert.Throws(() => reader.Read(stream, DocumentLocation, null!));
}
+ [Fact]
+ public void ReadReturnsDiagnosticErrorForExponentialAliasExpansion()
+ {
+ // A "billion laughs" YAML bomb must surface as a diagnostic error with no document,
+ // rather than throwing or exhausting memory.
+ var reader = new OpenApiYamlReader();
+ using var stream = CreateStream(
+ """
+ a: &a ["x","x","x","x","x","x","x","x","x"]
+ b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]
+ c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]
+ d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]
+ e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d]
+ f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e]
+ g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f]
+ h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g]
+ i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h]
+ """);
+
+ var result = reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings);
+
+ Assert.Null(result.Document);
+ Assert.NotEmpty(result.Diagnostic.Errors);
+ Assert.Equal(OpenApiConstants.Yaml, result.Diagnostic.Format);
+ }
+
private static MemoryStream CreateStream(string yaml)
{
return new MemoryStream(Encoding.UTF8.GetBytes(yaml));
diff --git a/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs
index c246e0898..e84ebe4cf 100644
--- a/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs
+++ b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs
@@ -333,6 +333,103 @@ public void RoundTripEmptyStringsValues()
Assert.Equal(yamlInput.MakeLineBreaksEnvironmentNeutral(), convertedBackOutput.MakeLineBreaksEnvironmentNeutral());
}
+ [Fact]
+ public void ExponentialAliasExpansionIsRejected()
+ {
+ // A "billion laughs" YAML bomb: each level references the previous one multiple times,
+ // so materializing the shared node graph into an independent JSON tree expands
+ // exponentially. The conversion must fail fast instead of exhausting memory.
+ var yamlBomb =
+ """
+ a: &a ["x","x","x","x","x","x","x","x","x"]
+ b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]
+ c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]
+ d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]
+ e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d]
+ f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e]
+ g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f]
+ h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g]
+ i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h]
+ """;
+
+ Assert.Throws(() => ConvertYamlStringToJsonNode(yamlBomb));
+ }
+
+ [Fact]
+ public void ExcessiveNestingDepthIsRejected()
+ {
+ // Deeper than the conversion depth limit (mirrors the System.Text.Json default of 64),
+ // which protects the recursive converter from stack exhaustion.
+ const int depth = 70;
+ var deeplyNested = new string('[', depth) + new string(']', depth);
+
+ Assert.Throws(() => ConvertYamlStringToJsonNode(deeplyNested));
+ }
+
+ [Fact]
+ public void LegitimateAliasesStillConvert()
+ {
+ var yamlInput =
+ """
+ a: &val hello
+ b: *val
+ """;
+
+ var jsonNode = Assert.IsType(ConvertYamlStringToJsonNode(yamlInput));
+
+ Assert.Equal("hello", jsonNode["a"]?.GetValue());
+ Assert.Equal("hello", jsonNode["b"]?.GetValue());
+ }
+
+ [Fact]
+ public void ConversionLimitsDefaultToDocumentedValues()
+ {
+ Assert.Equal(64, YamlConverter.DefaultMaxDepth);
+ Assert.Equal(5_000_000, YamlConverter.DefaultMaxNodeCount);
+ Assert.Equal(YamlConverter.DefaultMaxDepth, YamlConverter.MaxDepth);
+ Assert.Equal(YamlConverter.DefaultMaxNodeCount, YamlConverter.MaxNodeCount);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void SettingMaxDepthBelowOneThrows(int value)
+ {
+ Assert.Throws(() => YamlConverter.MaxDepth = value);
+ // The invalid assignment must not have changed the effective limit.
+ Assert.Equal(YamlConverter.DefaultMaxDepth, YamlConverter.MaxDepth);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void SettingMaxNodeCountBelowOneThrows(int value)
+ {
+ Assert.Throws(() => YamlConverter.MaxNodeCount = value);
+ // The invalid assignment must not have changed the effective limit.
+ Assert.Equal(YamlConverter.DefaultMaxNodeCount, YamlConverter.MaxNodeCount);
+ }
+
+ [Fact]
+ public void RaisingMaxDepthAllowsDocumentsDeeperThanTheDefault()
+ {
+ // A document nested deeper than the default depth limit (64) is rejected by default
+ // but can be permitted by a consumer that opts into a higher limit.
+ const int depth = 70;
+ var deeplyNested = new string('[', depth) + new string(']', depth);
+
+ try
+ {
+ YamlConverter.MaxDepth = depth + 10;
+ var jsonNode = ConvertYamlStringToJsonNode(deeplyNested);
+ Assert.IsType(jsonNode);
+ }
+ finally
+ {
+ YamlConverter.MaxDepth = YamlConverter.DefaultMaxDepth;
+ }
+ }
+
private static JsonNode ConvertYamlStringToJsonNode(string yamlInput)
{
var yamlDocument = new YamlStream();