Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/Microsoft.OpenApi.Readers/OpenApiReaderSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,64 @@ public enum ReferenceResolutionSetting
/// </summary>
public class OpenApiReaderSettings
{
/// <summary>
/// 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.
/// </summary>
public const uint DefaultMaxDepth = 64;

/// <summary>
/// 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.
/// </summary>
public const uint DefaultMaxNodeCount = 5_000_000;

private uint _maxDepth = DefaultMaxDepth;
private uint _maxNodeCount = DefaultMaxNodeCount;

/// <summary>
/// Gets or sets the maximum nesting depth allowed when materializing values from a node graph.
/// Defaults to <see cref="DefaultMaxDepth"/>. Raise this if legitimate deeply nested documents are
/// being rejected, or lower it to fail faster when only shallow documents are expected.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException">Thrown when set to zero.</exception>
public uint MaxDepth
{
get => _maxDepth;
set
{
if (value == 0)
{
throw new ArgumentOutOfRangeException(nameof(value), "MaxDepth must be greater than zero.");
}

_maxDepth = value;
}
}

/// <summary>
/// Gets or sets the maximum number of nodes that may be materialized from a single document.
/// Defaults to <see cref="DefaultMaxNodeCount"/>, 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.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException">Thrown when set to zero.</exception>
public uint MaxNodeCount
{
get => _maxNodeCount;
set
{
if (value == 0)
{
throw new ArgumentOutOfRangeException(nameof(value), "MaxNodeCount must be greater than zero.");
}

_maxNodeCount = value;
}
}

/// <summary>
/// Indicates how references in the source document should be handled.
/// </summary>
Expand Down
12 changes: 9 additions & 3 deletions src/Microsoft.OpenApi.Readers/OpenApiYamlDocumentReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -91,7 +93,9 @@ public async Task<ReadResult> 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;
Expand Down Expand Up @@ -184,7 +188,9 @@ public T ReadFragment<T>(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;
Expand Down
5 changes: 3 additions & 2 deletions src/Microsoft.OpenApi.Readers/ParseNodes/ListNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,13 @@ IEnumerator IEnumerable.GetEnumerator()
/// Create a <see cref="OpenApiArray"/>
/// </summary>
/// <returns>The created Any object.</returns>
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;
Expand Down
5 changes: 3 additions & 2 deletions src/Microsoft.OpenApi.Readers/ParseNodes/MapNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -213,12 +213,13 @@ public string GetScalarValue(ValueNode key)
/// Create a <see cref="OpenApiObject"/>
/// </summary>
/// <returns>The created Any object.</returns>
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;
Expand Down
25 changes: 24 additions & 1 deletion src/Microsoft.OpenApi.Readers/ParseNodes/ParseNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ internal abstract class ParseNode
protected ParseNode(ParsingContext parsingContext)
{
Context = parsingContext;
Context?.CountNode();
}

public ParsingContext Context { get; }
Expand Down Expand Up @@ -73,11 +74,33 @@ public virtual Dictionary<string, T> CreateSimpleMap<T>(Func<ValueNode, T> map)
throw new OpenApiReaderException("Cannot create simple map from this type of node.", Context);
}

public virtual IOpenApiAny CreateAny()
public IOpenApiAny CreateAny()
{
return CreateAny(0);
}

/// <summary>
/// Materializes the node, and everything below it, into an <see cref="IOpenApiAny"/>.
/// </summary>
/// <param name="depth">Nesting depth of the current node, bounded by <see cref="OpenApiReaderSettings.MaxDepth"/>.</param>
internal virtual IOpenApiAny CreateAny(uint depth)
{
throw new OpenApiReaderException("Cannot create an Any object this type of node.", Context);
}

/// <summary>
/// Fails fast when the node graph is nested more deeply than the reader supports,
/// protecting the recursive readers from stack exhaustion.
/// </summary>
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);
Expand Down
2 changes: 1 addition & 1 deletion src/Microsoft.OpenApi.Readers/ParseNodes/PropertyNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ public void ParseField<T>(
}
}

public override IOpenApiAny CreateAny()
internal override IOpenApiAny CreateAny(uint depth)
{
throw new NotImplementedException();
}
Expand Down
3 changes: 2 additions & 1 deletion src/Microsoft.OpenApi.Readers/ParseNodes/ValueNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ public override string GetScalarValue()
/// Create a <see cref="IOpenApiPrimitive"/>
/// </summary>
/// <returns>The created Any object.</returns>
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);
}
Expand Down
17 changes: 17 additions & 0 deletions src/Microsoft.OpenApi.Readers/ParsingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ public class ParsingContext
private readonly Dictionary<string, object> _tempStorage = new();
private readonly Dictionary<object, Dictionary<string, object>> _scopedTempStorage = new();
private readonly Dictionary<string, Stack<string>> _loopStacks = new();
private uint _nodeCount;
internal uint MaxDepth { get; set; } = OpenApiReaderSettings.DefaultMaxDepth;
internal uint MaxNodeCount { get; set; } = OpenApiReaderSettings.DefaultMaxNodeCount;
internal Dictionary<string, Func<IOpenApiAny, OpenApiSpecVersion, IOpenApiExtension>> ExtensionParsers { get; set; } = new();
internal RootNode RootNode { get; set; }
internal List<OpenApiTag> Tags { get; private set; } = new();
Expand Down Expand Up @@ -198,6 +201,20 @@ public void StartObject(string objectName)
_currentLocation.Push(objectName);
}

/// <summary>
/// Counts a node materialized while parsing the current document and fails fast when the
/// document expands beyond <see cref="OpenApiReaderSettings.MaxNodeCount"/>. 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.
/// </summary>
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.");
}
}

/// <summary>
/// Maintain history of traversals to avoid stack overflows from cycles
/// </summary>
Expand Down
142 changes: 142 additions & 0 deletions test/Microsoft.OpenApi.Readers.Tests/YamlAliasExpansionTests.cs
Original file line number Diff line number Diff line change
@@ -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<OpenApiReaderException>(() => 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<OpenApiReaderException>(() => 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<OpenApiObject>(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<ArgumentOutOfRangeException>(() => 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<ArgumentOutOfRangeException>(() => 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<OpenApiArray>(node.CreateAny());
}

private static string YamlBombIndented()
{
return " " + YamlBomb.Replace("\r\n", "\n").Replace("\n", "\n ");
}
}
}
Loading