From 4db9af07d112cdf45170b7a946f956ffe74fa14e Mon Sep 17 00:00:00 2001 From: Treicy Sanchez Date: Tue, 11 Aug 2026 06:55:54 -0600 Subject: [PATCH] fix: bound YAML anchor/alias expansion to prevent OOM (billion laughs) (#3000) * 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 * 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 * uint instead --------- Co-authored-by: Treicy Sanchez Gutierrez (from Dev Box) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 022bbd4f-e5e7-447a-bcdf-b2a4efaf75c3 --- .../OpenApiYamlReader.cs | 11 ++ .../PublicAPI.Unshipped.txt | 6 + .../YamlConverter.cs | 112 +++++++++++++- .../OpenApiYamlReaderTests.cs | 137 ++++++++++++++++++ .../YamlConverterTests.cs | 93 ++++++++++++ 5 files changed, 355 insertions(+), 4 deletions(-) create mode 100644 test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs 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..f19cde83f 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 -> uint +const Microsoft.OpenApi.YamlReader.YamlConverter.DefaultMaxNodeCount = 5000000 -> uint +static Microsoft.OpenApi.YamlReader.YamlConverter.MaxDepth.get -> uint +static Microsoft.OpenApi.YamlReader.YamlConverter.MaxDepth.set -> void +static Microsoft.OpenApi.YamlReader.YamlConverter.MaxNodeCount.get -> uint +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..3a09ff876 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 uint 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 uint DefaultMaxNodeCount = 5_000_000; + + private static uint _maxDepth = DefaultMaxDepth; + private static uint _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 zero. + public static uint MaxDepth + { + get => _maxDepth; + set + { + if (value == 0) + { + 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 zero. + public static uint MaxNodeCount + { + get => _maxNodeCount; + set + { + if (value == 0) + { + 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 uint _maxDepth; + private readonly uint _maxNodeCount; + private uint _nodeCount; + + public YamlConversionBudget(uint maxDepth, uint maxNodeCount) + { + _maxDepth = maxDepth; + _maxNodeCount = maxNodeCount; + } + + public void EnterNode(uint 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, uint 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, uint 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, uint 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 new file mode 100644 index 000000000..ea0ef0fd9 --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs @@ -0,0 +1,137 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.OpenApi.Reader; +using Microsoft.OpenApi.YamlReader; +using Xunit; + +namespace Microsoft.OpenApi.Readers.Tests; + +public class OpenApiYamlReaderTests +{ + private static readonly Uri DocumentLocation = new("https://contoso.test/openapi.yaml"); + + [Fact] + public async Task ReadAsyncParsesDocumentsFromNonMemoryStreams() + { + var reader = new OpenApiYamlReader(); + await using var stream = new NonMemoryStream(CreateStream( + """ + openapi: 3.0.1 + info: + title: Sample API + version: 1.0.0 + paths: {} + """)); + + var result = await reader.ReadAsync(stream, DocumentLocation, SettingsFixture.ReaderSettings, CancellationToken.None); + + Assert.NotNull(result.Document); + Assert.Equal("Sample API", result.Document.Info.Title); + Assert.Equal(OpenApiConstants.Yaml, result.Diagnostic.Format); + } + + [Fact] + public void ReadThrowsWhenYamlDoesNotContainADocument() + { + var reader = new OpenApiYamlReader(); + using var stream = CreateStream(string.Empty); + + var exception = Assert.Throws(() => reader.Read(stream, DocumentLocation, SettingsFixture.ReaderSettings)); + + Assert.Equal("No documents found in the YAML stream.", exception.Message); + } + + [Fact] + public void ReadFragmentParsesSchemaFragments() + { + var reader = new OpenApiYamlReader(); + using var stream = CreateStream( + """ + type: string + description: A reusable schema + """); + + var schema = reader.ReadFragment( + stream, + OpenApiSpecVersion.OpenApi3_0, + new OpenApiDocument(), + out var diagnostic); + + Assert.NotNull(schema); + Assert.Empty(diagnostic.Errors); + Assert.Equal(JsonSchemaType.String, schema.Type); + Assert.Equal("A reusable schema", schema.Description); + } + + [Fact] + public void ReadThrowsWhenSettingsIsNull() + { + var reader = new OpenApiYamlReader(); + using var stream = CreateStream("openapi: 3.0.1"); + + 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)); + } + + private sealed class NonMemoryStream(Stream innerStream) : Stream + { + public override bool CanRead => innerStream.CanRead; + public override bool CanSeek => innerStream.CanSeek; + public override bool CanWrite => innerStream.CanWrite; + public override long Length => innerStream.Length; + public override long Position + { + get => innerStream.Position; + set => innerStream.Position = value; + } + + public override void Flush() => innerStream.Flush(); + public override int Read(byte[] buffer, int offset, int count) => innerStream.Read(buffer, offset, count); + public override long Seek(long offset, SeekOrigin origin) => innerStream.Seek(offset, origin); + public override void SetLength(long value) => innerStream.SetLength(value); + public override void Write(byte[] buffer, int offset, int count) => innerStream.Write(buffer, offset, count); + public override ValueTask DisposeAsync() => innerStream.DisposeAsync(); + protected override void Dispose(bool disposing) + { + if (disposing) + { + innerStream.Dispose(); + } + + base.Dispose(disposing); + } + } +} diff --git a/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs index c246e0898..7bc094d48 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/YamlConverterTests.cs @@ -333,6 +333,99 @@ 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(64u, YamlConverter.DefaultMaxDepth); + Assert.Equal(5_000_000u, YamlConverter.DefaultMaxNodeCount); + Assert.Equal(YamlConverter.DefaultMaxDepth, YamlConverter.MaxDepth); + Assert.Equal(YamlConverter.DefaultMaxNodeCount, YamlConverter.MaxNodeCount); + } + + [Fact] + public void SettingMaxDepthToZeroThrows() + { + Assert.Throws(() => YamlConverter.MaxDepth = 0); + // The invalid assignment must not have changed the effective limit. + Assert.Equal(YamlConverter.DefaultMaxDepth, YamlConverter.MaxDepth); + } + + [Fact] + public void SettingMaxNodeCountToZeroThrows() + { + Assert.Throws(() => YamlConverter.MaxNodeCount = 0); + // 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();