From 675d4b380cdb71f4b7d1168232ee2131d352a776 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 14 Jul 2026 12:13:13 -0400 Subject: [PATCH 1/4] fix: adds explicit error message for invalid json pointers --- .../Reader/JsonNodeHelper.cs | 11 ++++++ .../Reader/V3/OpenApiV3Deserializer.cs | 2 ++ .../Reader/V31/OpenApiV31Deserializer.cs | 2 ++ .../V31Tests/OpenApiDocumentTests.cs | 34 +++++++++++++++++++ .../V3Tests/OpenApiDocumentTests.cs | 34 +++++++++++++++++++ 5 files changed, 83 insertions(+) diff --git a/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs b/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs index c01fd3d20..6104cdcb0 100644 --- a/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs +++ b/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs @@ -167,6 +167,17 @@ public static Dictionary> CreateArrayMap(this JsonNode? no return jsonObject.TryGetPropertyValue("$ref", out var refNode) ? refNode?.GetScalarValue() : null; } + public static void ValidateReferencePointerFormat(string pointer) + { + var hashIndex = pointer.IndexOf('#'); + if (hashIndex >= 0 && + pointer.Length > hashIndex + 1 && + pointer[hashIndex + 1] != '/') + { + throw new OpenApiException(string.Format(SRResource.ReferenceHasInvalidFormat, pointer)); + } + } + /// /// Returns the value of $dynamicRef if $ref is absent. Used to create a schema reference /// for bare $dynamicRef schemas (no $ref) so they participate in reference resolution. diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiV3Deserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiV3Deserializer.cs index ea499e098..fc2e40fed 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiV3Deserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiV3Deserializer.cs @@ -152,6 +152,8 @@ private static IOpenApiExtension LoadExtension(string name, JsonNode node, Parsi private static (string, string?) GetReferenceIdAndExternalResource(string pointer) { + JsonNodeHelper.ValidateReferencePointerFormat(pointer); + var refSegments = pointer.Split('/'); var refId = refSegments[refSegments.Count() -1]; var isExternalResource = !refSegments[0].StartsWith("#", StringComparison.OrdinalIgnoreCase); diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs index 81a7811ff..644431891 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs @@ -152,6 +152,8 @@ private static IOpenApiExtension LoadExtension(string name, JsonNode node, Parsi private static (string, string?) GetReferenceIdAndExternalResource(string pointer) { + JsonNodeHelper.ValidateReferencePointerFormat(pointer); + /* Check whether the reference pointer is a URL * (id keyword allows you to supply a URL for the schema as a target for referencing) * E.g. $ref: 'https://example.com/schemas/resource.json' diff --git a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentTests.cs index c6e62dec5..d7e3509ef 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V31Tests/OpenApiDocumentTests.cs @@ -16,6 +16,40 @@ public class OpenApiDocumentTests { private const string SampleFolderPath = "V31Tests/Samples/OpenApiDocument/"; + [Fact] + public void ParseDocumentWithMalformedReferenceShouldYieldExpectedDiagnostic() + { + var result = OpenApiDocument.Parse( + """ + openapi: 3.1.1 + info: + title: test + version: 1.0.0 + paths: + /test: + get: + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#components/schemas/Item' + components: + schemas: + Item: + type: object + properties: + id: + type: integer + """, + OpenApiConstants.Yaml, + SettingsFixture.ReaderSettings); + + var error = Assert.Single(result.Diagnostic.Errors); + Assert.Equal("The reference string '#components/schemas/Item' has invalid format.", error.Message); + } + [Fact] public async Task ParseDocumentWithWebhooksShouldSucceed() { diff --git a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDocumentTests.cs b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDocumentTests.cs index 10e4c4e0e..e34024b32 100644 --- a/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDocumentTests.cs +++ b/test/Microsoft.OpenApi.Readers.Tests/V3Tests/OpenApiDocumentTests.cs @@ -153,6 +153,40 @@ public async Task ParseBrokenMinimalDocumentShouldYieldExpectedDiagnostic() }, result.Diagnostic); } + [Fact] + public void ParseDocumentWithMalformedReferenceShouldYieldExpectedDiagnostic() + { + var result = OpenApiDocument.Parse( + """ + openapi: 3.0.3 + info: + title: test + version: 1.0.0 + paths: + /test: + get: + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#components/schemas/Item' + components: + schemas: + Item: + type: object + properties: + id: + type: integer + """, + OpenApiConstants.Yaml, + SettingsFixture.ReaderSettings); + + var error = Assert.Single(result.Diagnostic.Errors); + Assert.Equal("The reference string '#components/schemas/Item' has invalid format.", error.Message); + } + [Fact] public async Task ParseMinimalDocumentShouldSucceed() { From 96b2c34e71db99f0dfb4bf57dd895386f333c4cb Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 14 Jul 2026 12:22:34 -0400 Subject: [PATCH 2/4] chore: linting Signed-off-by: Vincent Biret --- src/Microsoft.OpenApi/Reader/V2/OpenApiV2Deserializer.cs | 4 ++-- src/Microsoft.OpenApi/Reader/V3/OpenApiV3Deserializer.cs | 2 +- src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.OpenApi/Reader/V2/OpenApiV2Deserializer.cs b/src/Microsoft.OpenApi/Reader/V2/OpenApiV2Deserializer.cs index 3996d8d7a..0e38b6842 100644 --- a/src/Microsoft.OpenApi/Reader/V2/OpenApiV2Deserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V2/OpenApiV2Deserializer.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System; @@ -88,7 +88,7 @@ private static IOpenApiExtension LoadExtension(string name, JsonNode node, Parsi private static (string, string?) GetReferenceIdAndExternalResource(string pointer) { var refSegments = pointer.Split('/'); - var refId = refSegments[refSegments.Count() -1]; + var refId = refSegments[^1]; var isExternalResource = !refSegments[0].StartsWith("#", StringComparison.OrdinalIgnoreCase); string? externalResource = isExternalResource ? $"{refSegments[0]}/{refSegments[1].TrimEnd('#')}" : null; diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiV3Deserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiV3Deserializer.cs index fc2e40fed..fc7f6e309 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiV3Deserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiV3Deserializer.cs @@ -155,7 +155,7 @@ private static (string, string?) GetReferenceIdAndExternalResource(string pointe JsonNodeHelper.ValidateReferencePointerFormat(pointer); var refSegments = pointer.Split('/'); - var refId = refSegments[refSegments.Count() -1]; + var refId = refSegments[^1]; var isExternalResource = !refSegments[0].StartsWith("#", StringComparison.OrdinalIgnoreCase); string? externalResource = null; diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs index 644431891..edc9c2be4 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. using System; @@ -161,7 +161,7 @@ private static (string, string?) GetReferenceIdAndExternalResource(string pointe * E.g. $ref: '#/components/schemas/pet' */ var refSegments = pointer.Split('/'); - string refId = !pointer.Contains('#') ? pointer : refSegments[refSegments.Count()-1]; + string refId = !pointer.Contains('#') ? pointer : refSegments[^1]; var isExternalResource = !refSegments[0].StartsWith("#", StringComparison.OrdinalIgnoreCase); string? externalResource = null; From 1ab21170c53bb6ce2ceef5d8d5a9385996fe99da Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 14 Jul 2026 12:28:18 -0400 Subject: [PATCH 3/4] chore: fixes implementation for pointer validation Signed-off-by: Vincent Biret --- src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs b/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs index 6104cdcb0..e9fec4fe1 100644 --- a/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs +++ b/src/Microsoft.OpenApi/Reader/JsonNodeHelper.cs @@ -170,9 +170,10 @@ public static Dictionary> CreateArrayMap(this JsonNode? no public static void ValidateReferencePointerFormat(string pointer) { var hashIndex = pointer.IndexOf('#'); - if (hashIndex >= 0 && - pointer.Length > hashIndex + 1 && - pointer[hashIndex + 1] != '/') + if (hashIndex < 0) return; + var slashIndex = pointer.IndexOf('/', hashIndex); + if (slashIndex >= 0 && + slashIndex != hashIndex + 1) { throw new OpenApiException(string.Format(SRResource.ReferenceHasInvalidFormat, pointer)); } From 7d7f8da844c02925c254445c499be31a691f6621 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 14 Jul 2026 12:31:11 -0400 Subject: [PATCH 4/4] chore: reverts range index unavailable on netstandard2.0 Signed-off-by: Vincent Biret --- src/Microsoft.OpenApi/Reader/V2/OpenApiV2Deserializer.cs | 2 +- src/Microsoft.OpenApi/Reader/V3/OpenApiV3Deserializer.cs | 2 +- src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.OpenApi/Reader/V2/OpenApiV2Deserializer.cs b/src/Microsoft.OpenApi/Reader/V2/OpenApiV2Deserializer.cs index 0e38b6842..ab3d15e94 100644 --- a/src/Microsoft.OpenApi/Reader/V2/OpenApiV2Deserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V2/OpenApiV2Deserializer.cs @@ -88,7 +88,7 @@ private static IOpenApiExtension LoadExtension(string name, JsonNode node, Parsi private static (string, string?) GetReferenceIdAndExternalResource(string pointer) { var refSegments = pointer.Split('/'); - var refId = refSegments[^1]; + var refId = refSegments[refSegments.Length - 1]; var isExternalResource = !refSegments[0].StartsWith("#", StringComparison.OrdinalIgnoreCase); string? externalResource = isExternalResource ? $"{refSegments[0]}/{refSegments[1].TrimEnd('#')}" : null; diff --git a/src/Microsoft.OpenApi/Reader/V3/OpenApiV3Deserializer.cs b/src/Microsoft.OpenApi/Reader/V3/OpenApiV3Deserializer.cs index fc7f6e309..843700f44 100644 --- a/src/Microsoft.OpenApi/Reader/V3/OpenApiV3Deserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V3/OpenApiV3Deserializer.cs @@ -155,7 +155,7 @@ private static (string, string?) GetReferenceIdAndExternalResource(string pointe JsonNodeHelper.ValidateReferencePointerFormat(pointer); var refSegments = pointer.Split('/'); - var refId = refSegments[^1]; + var refId = refSegments[refSegments.Length - 1]; var isExternalResource = !refSegments[0].StartsWith("#", StringComparison.OrdinalIgnoreCase); string? externalResource = null; diff --git a/src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs b/src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs index edc9c2be4..617ee9ced 100644 --- a/src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs +++ b/src/Microsoft.OpenApi/Reader/V31/OpenApiV31Deserializer.cs @@ -161,7 +161,7 @@ private static (string, string?) GetReferenceIdAndExternalResource(string pointe * E.g. $ref: '#/components/schemas/pet' */ var refSegments = pointer.Split('/'); - string refId = !pointer.Contains('#') ? pointer : refSegments[^1]; + string refId = !pointer.Contains('#') ? pointer : refSegments[refSegments.Length - 1]; var isExternalResource = !refSegments[0].StartsWith("#", StringComparison.OrdinalIgnoreCase); string? externalResource = null;