From 00c52aaa8b5c5dd27e54a95664d032057d252aff Mon Sep 17 00:00:00 2001 From: "Treicy Sanchez Gutierrez (from Dev Box)" Date: Fri, 7 Aug 2026 11:03:26 -0600 Subject: [PATCH 1/2] 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 --- .../OpenApiYamlReader.cs | 11 +++ .../YamlConverter.cs | 69 +++++++++++++++++-- .../OpenApiYamlReaderTests.cs | 26 +++++++ .../YamlConverterTests.cs | 48 +++++++++++++ 4 files changed, 150 insertions(+), 4 deletions(-) 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/YamlConverter.cs b/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs index 1cafea77b..c88e389ad 100644 --- a/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs +++ b/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs @@ -14,6 +14,51 @@ 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. + /// + internal 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. + /// Increase this only if legitimate large documents are being rejected. + /// + internal const int DefaultMaxNodeCount = 5_000_000; + + /// + /// 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 = DefaultMaxDepth, int maxNodeCount = DefaultMaxNodeCount) + { + _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 +87,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(), 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 +129,17 @@ public static YamlNode ToYamlNode(this JsonNode json) /// /// public static JsonObject ToJsonObject(this YamlMappingNode yaml) + { + return yaml.ToJsonObject(new YamlConversionBudget(), 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 +159,16 @@ private static YamlMappingNode ToYamlMapping(this JsonObject obj) /// /// public static JsonArray ToJsonArray(this YamlSequenceNode yaml) + { + return yaml.ToJsonArray(new YamlConversionBudget(), 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..c653b3e2f 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs @@ -333,6 +333,54 @@ 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()); + } + private static JsonNode ConvertYamlStringToJsonNode(string yamlInput) { var yamlDocument = new YamlStream(); From a1869ef92b31084cb056cabd5b192634c8cb00ba Mon Sep 17 00:00:00 2001 From: "Treicy Sanchez Gutierrez (from Dev Box)" Date: Fri, 7 Aug 2026 13:47:58 -0600 Subject: [PATCH 2/2] 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 --- .../PublicAPI.Unshipped.txt | 6 ++ .../YamlConverter.cs | 57 ++++++++++++++++--- .../YamlConverterTests.cs | 49 ++++++++++++++++ 3 files changed, 105 insertions(+), 7 deletions(-) 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 c88e389ad..fbd4f5d18 100644 --- a/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs +++ b/src/Microsoft.OpenApi.YamlReader/YamlConverter.cs @@ -19,15 +19,58 @@ public static class YamlConverter /// 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. /// - internal const int DefaultMaxDepth = 64; + 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. - /// Increase this only if legitimate large documents are being rejected. /// - internal const int DefaultMaxNodeCount = 5_000_000; + 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, @@ -39,7 +82,7 @@ private sealed class YamlConversionBudget private readonly int _maxNodeCount; private int _nodeCount; - public YamlConversionBudget(int maxDepth = DefaultMaxDepth, int maxNodeCount = DefaultMaxNodeCount) + public YamlConversionBudget(int maxDepth, int maxNodeCount) { _maxDepth = maxDepth; _maxNodeCount = maxNodeCount; @@ -87,7 +130,7 @@ 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(), 0); + return yaml.ToJsonNode(new YamlConversionBudget(MaxDepth, MaxNodeCount), 0); } private static JsonNode ToJsonNode(this YamlNode yaml, YamlConversionBudget budget, int depth) @@ -130,7 +173,7 @@ public static YamlNode ToYamlNode(this JsonNode json) /// public static JsonObject ToJsonObject(this YamlMappingNode yaml) { - return yaml.ToJsonObject(new YamlConversionBudget(), 0); + return yaml.ToJsonObject(new YamlConversionBudget(MaxDepth, MaxNodeCount), 0); } private static JsonObject ToJsonObject(this YamlMappingNode yaml, YamlConversionBudget budget, int depth) @@ -160,7 +203,7 @@ private static YamlMappingNode ToYamlMapping(this JsonObject obj) /// public static JsonArray ToJsonArray(this YamlSequenceNode yaml) { - return yaml.ToJsonArray(new YamlConversionBudget(), 0); + return yaml.ToJsonArray(new YamlConversionBudget(MaxDepth, MaxNodeCount), 0); } private static JsonArray ToJsonArray(this YamlSequenceNode yaml, YamlConversionBudget budget, int depth) diff --git a/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs index c653b3e2f..e84ebe4cf 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs @@ -381,6 +381,55 @@ public void LegitimateAliasesStillConvert() 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();