diff --git a/src/Microsoft.OpenApi.Readers/OpenApiReaderSettings.cs b/src/Microsoft.OpenApi.Readers/OpenApiReaderSettings.cs index 5d251802f..8f5bd4fa7 100644 --- a/src/Microsoft.OpenApi.Readers/OpenApiReaderSettings.cs +++ b/src/Microsoft.OpenApi.Readers/OpenApiReaderSettings.cs @@ -36,6 +36,64 @@ public enum ReferenceResolutionSetting /// public class OpenApiReaderSettings { + /// + /// Default maximum nesting depth allowed when materializing values from a YAML/JSON node graph. + /// Mirrors the default System.Text.Json depth limit (64), protecting the recursive readers + /// from stack exhaustion on deeply nested documents. + /// + public const uint DefaultMaxDepth = 64; + + /// + /// Default maximum number of nodes that may be materialized from a single 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 tree. + /// + public const uint DefaultMaxNodeCount = 5_000_000; + + private uint _maxDepth = DefaultMaxDepth; + private uint _maxNodeCount = DefaultMaxNodeCount; + + /// + /// Gets or sets the maximum nesting depth allowed when materializing values from a node graph. + /// 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 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 nodes that may be materialized from a single 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 uint MaxNodeCount + { + get => _maxNodeCount; + set + { + if (value == 0) + { + throw new ArgumentOutOfRangeException(nameof(value), "MaxNodeCount must be greater than zero."); + } + + _maxNodeCount = value; + } + } + /// /// Indicates how references in the source document should be handled. /// diff --git a/src/Microsoft.OpenApi.Readers/OpenApiYamlDocumentReader.cs b/src/Microsoft.OpenApi.Readers/OpenApiYamlDocumentReader.cs index af9ebcad1..73349e45d 100644 --- a/src/Microsoft.OpenApi.Readers/OpenApiYamlDocumentReader.cs +++ b/src/Microsoft.OpenApi.Readers/OpenApiYamlDocumentReader.cs @@ -47,7 +47,9 @@ public OpenApiDocument Read(YamlDocument input, out OpenApiDiagnostic diagnostic { ExtensionParsers = _settings.ExtensionParsers, BaseUrl = _settings.BaseUrl, - DefaultContentType = _settings.DefaultContentType + DefaultContentType = _settings.DefaultContentType, + MaxDepth = _settings.MaxDepth, + MaxNodeCount = _settings.MaxNodeCount }; OpenApiDocument document = null; @@ -91,7 +93,9 @@ public async Task ReadAsync(YamlDocument input, CancellationToken ca var context = new ParsingContext(diagnostic) { ExtensionParsers = _settings.ExtensionParsers, - BaseUrl = _settings.BaseUrl + BaseUrl = _settings.BaseUrl, + MaxDepth = _settings.MaxDepth, + MaxNodeCount = _settings.MaxNodeCount }; OpenApiDocument document = null; @@ -184,7 +188,9 @@ public T ReadFragment(YamlDocument input, OpenApiSpecVersion version, out Ope diagnostic = new(); var context = new ParsingContext(diagnostic) { - ExtensionParsers = _settings.ExtensionParsers + ExtensionParsers = _settings.ExtensionParsers, + MaxDepth = _settings.MaxDepth, + MaxNodeCount = _settings.MaxNodeCount }; IOpenApiElement element = null; diff --git a/src/Microsoft.OpenApi.Readers/ParseNodes/ListNode.cs b/src/Microsoft.OpenApi.Readers/ParseNodes/ListNode.cs index dbeadb5d1..02fb80416 100644 --- a/src/Microsoft.OpenApi.Readers/ParseNodes/ListNode.cs +++ b/src/Microsoft.OpenApi.Readers/ParseNodes/ListNode.cs @@ -64,12 +64,13 @@ IEnumerator IEnumerable.GetEnumerator() /// Create a /// /// The created Any object. - public override IOpenApiAny CreateAny() + internal override IOpenApiAny CreateAny(uint depth) { + EnsureDepthWithinLimit(depth); var array = new OpenApiArray(); foreach (var node in this) { - array.Add(node.CreateAny()); + array.Add(node.CreateAny(depth + 1)); } return array; diff --git a/src/Microsoft.OpenApi.Readers/ParseNodes/MapNode.cs b/src/Microsoft.OpenApi.Readers/ParseNodes/MapNode.cs index 61f609817..e348f3258 100644 --- a/src/Microsoft.OpenApi.Readers/ParseNodes/MapNode.cs +++ b/src/Microsoft.OpenApi.Readers/ParseNodes/MapNode.cs @@ -213,12 +213,13 @@ public string GetScalarValue(ValueNode key) /// Create a /// /// The created Any object. - public override IOpenApiAny CreateAny() + internal override IOpenApiAny CreateAny(uint depth) { + EnsureDepthWithinLimit(depth); var apiObject = new OpenApiObject(); foreach (var node in this) { - apiObject.Add(node.Name, node.Value.CreateAny()); + apiObject.Add(node.Name, node.Value.CreateAny(depth + 1)); } return apiObject; diff --git a/src/Microsoft.OpenApi.Readers/ParseNodes/ParseNode.cs b/src/Microsoft.OpenApi.Readers/ParseNodes/ParseNode.cs index 028371c39..ba870f192 100644 --- a/src/Microsoft.OpenApi.Readers/ParseNodes/ParseNode.cs +++ b/src/Microsoft.OpenApi.Readers/ParseNodes/ParseNode.cs @@ -16,6 +16,7 @@ internal abstract class ParseNode protected ParseNode(ParsingContext parsingContext) { Context = parsingContext; + Context?.CountNode(); } public ParsingContext Context { get; } @@ -73,11 +74,33 @@ public virtual Dictionary CreateSimpleMap(Func map) throw new OpenApiReaderException("Cannot create simple map from this type of node.", Context); } - public virtual IOpenApiAny CreateAny() + public IOpenApiAny CreateAny() + { + return CreateAny(0); + } + + /// + /// Materializes the node, and everything below it, into an . + /// + /// Nesting depth of the current node, bounded by . + internal virtual IOpenApiAny CreateAny(uint depth) { throw new OpenApiReaderException("Cannot create an Any object this type of node.", Context); } + /// + /// Fails fast when the node graph is nested more deeply than the reader supports, + /// protecting the recursive readers from stack exhaustion. + /// + protected void EnsureDepthWithinLimit(uint depth) + { + var maxDepth = Context?.MaxDepth ?? OpenApiReaderSettings.DefaultMaxDepth; + if (depth > maxDepth) + { + throw new OpenApiReaderException($"The document exceeds the maximum supported nesting depth of {maxDepth}.", Context); + } + } + public virtual string GetRaw() { throw new OpenApiReaderException("Cannot get raw value from this type of node.", Context); diff --git a/src/Microsoft.OpenApi.Readers/ParseNodes/PropertyNode.cs b/src/Microsoft.OpenApi.Readers/ParseNodes/PropertyNode.cs index 6a059c348..8646646e5 100644 --- a/src/Microsoft.OpenApi.Readers/ParseNodes/PropertyNode.cs +++ b/src/Microsoft.OpenApi.Readers/ParseNodes/PropertyNode.cs @@ -82,7 +82,7 @@ public void ParseField( } } - public override IOpenApiAny CreateAny() + internal override IOpenApiAny CreateAny(uint depth) { throw new NotImplementedException(); } diff --git a/src/Microsoft.OpenApi.Readers/ParseNodes/ValueNode.cs b/src/Microsoft.OpenApi.Readers/ParseNodes/ValueNode.cs index 1aeccb8e7..d57bb69d1 100644 --- a/src/Microsoft.OpenApi.Readers/ParseNodes/ValueNode.cs +++ b/src/Microsoft.OpenApi.Readers/ParseNodes/ValueNode.cs @@ -31,8 +31,9 @@ public override string GetScalarValue() /// Create a /// /// The created Any object. - public override IOpenApiAny CreateAny() + internal override IOpenApiAny CreateAny(uint depth) { + EnsureDepthWithinLimit(depth); var value = GetScalarValue(); return new OpenApiString(value, this._node.Style is ScalarStyle.SingleQuoted or ScalarStyle.DoubleQuoted or ScalarStyle.Literal or ScalarStyle.Folded); } diff --git a/src/Microsoft.OpenApi.Readers/ParsingContext.cs b/src/Microsoft.OpenApi.Readers/ParsingContext.cs index 6227514b8..228151be1 100644 --- a/src/Microsoft.OpenApi.Readers/ParsingContext.cs +++ b/src/Microsoft.OpenApi.Readers/ParsingContext.cs @@ -25,6 +25,9 @@ public class ParsingContext private readonly Dictionary _tempStorage = new(); private readonly Dictionary> _scopedTempStorage = new(); private readonly Dictionary> _loopStacks = new(); + private uint _nodeCount; + internal uint MaxDepth { get; set; } = OpenApiReaderSettings.DefaultMaxDepth; + internal uint MaxNodeCount { get; set; } = OpenApiReaderSettings.DefaultMaxNodeCount; internal Dictionary> ExtensionParsers { get; set; } = new(); internal RootNode RootNode { get; set; } internal List Tags { get; private set; } = new(); @@ -198,6 +201,20 @@ public void StartObject(string objectName) _currentLocation.Push(objectName); } + /// + /// Counts a node materialized while parsing the current document and fails fast when the + /// document expands beyond . YAML anchors and + /// aliases share a single node in the source graph, so a tiny document can expand + /// exponentially ("billion laughs") when it is materialized into an independent tree. + /// + internal void CountNode() + { + if (++_nodeCount > MaxNodeCount) + { + throw new OpenApiReaderException($"The document expands to more than the maximum supported number of nodes ({MaxNodeCount}). This may indicate a YAML anchor/alias expansion (billion laughs) attack."); + } + } + /// /// Maintain history of traversals to avoid stack overflows from cycles /// diff --git a/test/Microsoft.OpenApi.Readers.Tests/YamlAliasExpansionTests.cs b/test/Microsoft.OpenApi.Readers.Tests/YamlAliasExpansionTests.cs new file mode 100644 index 000000000..53ba6852f --- /dev/null +++ b/test/Microsoft.OpenApi.Readers.Tests/YamlAliasExpansionTests.cs @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +using System; +using FluentAssertions; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Readers; +using Microsoft.OpenApi.Readers.Exceptions; +using Microsoft.OpenApi.Readers.ParseNodes; +using Xunit; + +namespace Microsoft.OpenApi.Tests +{ + [Collection("DefaultSettings")] + public class YamlAliasExpansionTests + { + // A "billion laughs" YAML bomb: each level references the previous one multiple times, + // so materializing the shared node graph into an independent object tree expands + // exponentially. The conversion must fail fast instead of exhausting memory. + private const string 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] + """; + + [Fact] + public void ExponentialAliasExpansionIsRejected() + { + var node = ParseNode.Create(new(new()), YamlHelper.ParseYamlString(YamlBomb)); + + Assert.Throws(() => node.CreateAny()); + } + + [Fact] + public void ExcessiveNestingDepthIsRejected() + { + // Deeper than the conversion depth limit, which protects the recursive + // converter from stack exhaustion. + const int depth = 70; + var deeplyNested = new string('[', depth) + new string(']', depth); + + var node = ParseNode.Create(new(new()), YamlHelper.ParseYamlString(deeplyNested)); + + Assert.Throws(() => node.CreateAny()); + } + + [Fact] + public void ReadReturnsDiagnosticErrorForExponentialAliasExpansion() + { + // A "billion laughs" YAML bomb must surface as a diagnostic error + // rather than throwing or exhausting memory. + var input = + $$""" + openapi: 3.0.0 + info: + title: bomb + version: 1.0.0 + paths: {} + x-bomb: + {{YamlBombIndented()}} + """; + + var reader = new OpenApiStringReader(); + reader.Read(input, out var diagnostic); + + diagnostic.Errors.Should().NotBeEmpty(); + } + + [Fact] + public void LegitimateAliasesStillConvert() + { + var input = + """ + a: &val hello + b: *val + """; + + var node = ParseNode.Create(new(new()), YamlHelper.ParseYamlString(input)); + + var anyObject = Assert.IsType(node.CreateAny()); + Assert.Equal("hello", ((OpenApiString)anyObject["a"]).Value); + Assert.Equal("hello", ((OpenApiString)anyObject["b"]).Value); + } + + [Fact] + public void ConversionLimitsDefaultToDocumentedValues() + { + var settings = new OpenApiReaderSettings(); + + Assert.Equal(64u, OpenApiReaderSettings.DefaultMaxDepth); + Assert.Equal(5_000_000u, OpenApiReaderSettings.DefaultMaxNodeCount); + Assert.Equal(OpenApiReaderSettings.DefaultMaxDepth, settings.MaxDepth); + Assert.Equal(OpenApiReaderSettings.DefaultMaxNodeCount, settings.MaxNodeCount); + } + + [Fact] + public void SettingMaxDepthToZeroThrows() + { + var settings = new OpenApiReaderSettings(); + + Assert.Throws(() => settings.MaxDepth = 0); + // The invalid assignment must not have changed the effective limit. + Assert.Equal(OpenApiReaderSettings.DefaultMaxDepth, settings.MaxDepth); + } + + [Fact] + public void SettingMaxNodeCountToZeroThrows() + { + var settings = new OpenApiReaderSettings(); + + Assert.Throws(() => settings.MaxNodeCount = 0); + // The invalid assignment must not have changed the effective limit. + Assert.Equal(OpenApiReaderSettings.DefaultMaxNodeCount, settings.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); + + var context = new ParsingContext(new()) { MaxDepth = depth + 10 }; + var node = ParseNode.Create(context, YamlHelper.ParseYamlString(deeplyNested)); + + Assert.IsType(node.CreateAny()); + } + + private static string YamlBombIndented() + { + return " " + YamlBomb.Replace("\r\n", "\n").Replace("\n", "\n "); + } + } +}