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
11 changes: 11 additions & 0 deletions src/Microsoft.OpenApi.YamlReader/OpenApiYamlReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
6 changes: 6 additions & 0 deletions src/Microsoft.OpenApi.YamlReader/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -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
112 changes: 108 additions & 4 deletions src/Microsoft.OpenApi.YamlReader/YamlConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,94 @@
/// </summary>
public static class YamlConverter
{
/// <summary>
/// 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.
/// </summary>
public const uint DefaultMaxDepth = 64;

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

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

/// <summary>
/// Gets or sets the maximum nesting depth allowed when converting a YAML node graph into JSON nodes.
/// 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 static 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 JSON nodes that may be materialized from a single YAML 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 static uint MaxNodeCount
{
get => _maxNodeCount;
set
{
if (value == 0)
{
throw new ArgumentOutOfRangeException(nameof(value), "MaxNodeCount must be greater than zero.");
}

_maxNodeCount = value;
}
}

/// <summary>
/// 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.
/// </summary>
private sealed class YamlConversionBudget
{
private readonly uint _maxDepth;

Check failure on line 81 in src/Microsoft.OpenApi.YamlReader/YamlConverter.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this field to not shadow the outer class' member with the same name.

See more on https://sonarcloud.io/project/issues?id=microsoft_OpenAPI.NET&issues=AZ_w9A9pliFyBlXgGADs&open=AZ_w9A9pliFyBlXgGADs&pullRequest=3005
private readonly uint _maxNodeCount;

Check failure on line 82 in src/Microsoft.OpenApi.YamlReader/YamlConverter.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this field to not shadow the outer class' member with the same name.

See more on https://sonarcloud.io/project/issues?id=microsoft_OpenAPI.NET&issues=AZ_w9A9pliFyBlXgGADt&open=AZ_w9A9pliFyBlXgGADt&pullRequest=3005
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.");
}
}
}

/// <summary>
/// Converts all of the documents in a YAML stream to <see cref="JsonNode"/>s.
/// </summary>
Expand Down Expand Up @@ -42,10 +130,16 @@
/// <exception cref="NotSupportedException">Thrown for YAML that is not compatible with JSON.</exception>
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")
};
Expand Down Expand Up @@ -78,12 +172,17 @@
/// <param name="yaml"></param>
/// <returns></returns>
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;
Expand All @@ -103,11 +202,16 @@
/// <param name="yaml"></param>
/// <returns></returns>
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;
Expand Down
137 changes: 137 additions & 0 deletions test/Microsoft.OpenApi.Readers.Tests/OpenApiYamlReaderTests.cs
Original file line number Diff line number Diff line change
@@ -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<InvalidOperationException>(() => 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<OpenApiSchema>(
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<ArgumentNullException>(() => 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);
}
}
}
Loading
Loading