From 2f2b731322695ee6b27a97643ebbb9ed1eb18dfc Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Thu, 27 Feb 2025 11:49:23 -0800 Subject: [PATCH 1/3] Support resolving OpenAPI server URLs from HttpRequest (#60617) * Support resolving OpenAPI server URLs from HttpRequest * Try passing optional params everywhere --- .../OpenApiEndpointRouteBuilderExtensions.cs | 2 +- .../src/Services/OpenApiDocumentService.cs | 23 +++++++++-- .../OpenApiDocumentServiceTests.Servers.cs | 40 +++++++++++++++++++ .../OpenApiDocumentServiceTestsBase.cs | 6 +-- 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs index c5bed38669e4..de74fd8d1257 100644 --- a/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs +++ b/src/OpenApi/src/Extensions/OpenApiEndpointRouteBuilderExtensions.cs @@ -43,7 +43,7 @@ public static IEndpointConventionBuilder MapOpenApi(this IEndpointRouteBuilder e } else { - var document = await documentService.GetOpenApiDocumentAsync(context.RequestServices, context.RequestAborted); + var document = await documentService.GetOpenApiDocumentAsync(context.RequestServices, context.Request, context.RequestAborted); var documentOptions = options.Get(documentName); using var output = MemoryBufferWriter.Get(); using var writer = Utf8BufferTextWriter.Get(output); diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index 5d678e67c8c7..b907cc6ecb20 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApiExplorer; @@ -55,7 +56,7 @@ internal sealed class OpenApiDocumentService( internal bool TryGetCachedOperationTransformerContext(string descriptionId, [NotNullWhen(true)] out OpenApiOperationTransformerContext? context) => _operationTransformerContextCache.TryGetValue(descriptionId, out context); - public async Task GetOpenApiDocumentAsync(IServiceProvider scopedServiceProvider, CancellationToken cancellationToken = default) + public async Task GetOpenApiDocumentAsync(IServiceProvider scopedServiceProvider, HttpRequest? httpRequest = null, CancellationToken cancellationToken = default) { // For good hygiene, operation-level tags must also appear in the document-level // tags collection. This set captures all tags that have been seen so far. @@ -74,7 +75,7 @@ public async Task GetOpenApiDocumentAsync(IServiceProvider scop { Info = GetOpenApiInfo(), Paths = await GetOpenApiPathsAsync(capturedTags, scopedServiceProvider, operationTransformers, schemaTransformers, cancellationToken), - Servers = GetOpenApiServers(), + Servers = GetOpenApiServers(httpRequest), Tags = [.. capturedTags] }; try @@ -192,12 +193,26 @@ internal OpenApiInfo GetOpenApiInfo() }; } - internal List GetOpenApiServers() + // Resolve server URL from the request to handle reverse proxies. + // If there is active request object, assume a development environment and use the server addresses. + internal List GetOpenApiServers(HttpRequest? httpRequest = null) + { + if (httpRequest is not null) + { + var serverUrl = UriHelper.BuildAbsolute(httpRequest.Scheme, httpRequest.Host, httpRequest.PathBase); + return [new OpenApiServer { Url = serverUrl }]; + } + else + { + return GetDevelopmentOpenApiServers(); + } + } + private List GetDevelopmentOpenApiServers() { if (hostEnvironment.IsDevelopment() && server?.Features.Get()?.Addresses is { Count: > 0 } addresses) { - return addresses.Select(address => new OpenApiServer { Url = address }).ToList(); + return [.. addresses.Select(address => new OpenApiServer { Url = address })]; } return []; } diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Servers.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Servers.cs index c84c7e258510..1bc247c95ad4 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Servers.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Servers.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.OpenApi; using Microsoft.Extensions.DependencyInjection; @@ -10,6 +11,45 @@ public partial class OpenApiDocumentServiceTests { + [Theory] + [InlineData("Development", "localhost:5001", "", "http", "http://localhost:5001/")] + [InlineData("Development", "example.com", "/api", "https", "https://example.com/api")] + [InlineData("Staging", "localhost:5002", "/v1", "http", "http://localhost:5002/v1")] + [InlineData("Staging", "api.example.com", "/base/path", "https", "https://api.example.com/base/path")] + [InlineData("Development", "localhost", "/", "http", "http://localhost/")] + public void GetOpenApiServers_FavorsHttpContextRequestOverServerAddress(string environment, string host, string pathBase, string scheme, string expectedUri) + { + // Arrange + var hostEnvironment = new HostingEnvironment + { + ApplicationName = "TestApplication", + EnvironmentName = environment + }; + var docService = new OpenApiDocumentService( + "v1", + new Mock().Object, + hostEnvironment, + GetMockOptionsMonitor(), + new Mock().Object, + new OpenApiTestServer(["http://localhost:5000"])); + var httpContext = new DefaultHttpContext() + { + Request = + { + Host = new HostString(host), + PathBase = pathBase, + Scheme = scheme + + } + }; + + // Act + var servers = docService.GetOpenApiServers(httpContext.Request); + + // Assert + Assert.Contains(expectedUri, servers.Select(s => s.Url)); + } + [Fact] public void GetOpenApiServers_HandlesServerAddressFeatureWithValues() { diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs index e773ebf5ff89..b33eb153de4c 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentServiceTestsBase.cs @@ -35,16 +35,16 @@ public static async Task VerifyOpenApiDocument(IEndpointRouteBuilder builder, Op { var documentService = CreateDocumentService(builder, openApiOptions); var scopedService = ((TestServiceProvider)builder.ServiceProvider).CreateScope(); - var document = await documentService.GetOpenApiDocumentAsync(scopedService.ServiceProvider, cancellationToken); + var document = await documentService.GetOpenApiDocumentAsync(scopedService.ServiceProvider, null, cancellationToken); verifyOpenApiDocument(document); } - public static async Task VerifyOpenApiDocument(ActionDescriptor action, Action verifyOpenApiDocument) + public static async Task VerifyOpenApiDocument(ActionDescriptor action, Action verifyOpenApiDocument, CancellationToken cancellationToken = default) { var builder = CreateBuilder(); var documentService = CreateDocumentService(builder, action); var scopedService = ((TestServiceProvider)builder.ServiceProvider).CreateScope(); - var document = await documentService.GetOpenApiDocumentAsync(scopedService.ServiceProvider); + var document = await documentService.GetOpenApiDocumentAsync(scopedService.ServiceProvider, null); verifyOpenApiDocument(document); } From bf991f64c789c0c7e4cbb650b44ff561afc6cbef Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Fri, 28 Feb 2025 23:30:54 +0000 Subject: [PATCH 2/3] Fix up handling for forwarded headers --- .../src/Services/OpenApiDocumentService.cs | 14 +++++- .../OpenApiDocumentServiceTests.Servers.cs | 43 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index b907cc6ecb20..d31a0443cf65 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -199,7 +199,18 @@ internal List GetOpenApiServers(HttpRequest? httpRequest = null) { if (httpRequest is not null) { - var serverUrl = UriHelper.BuildAbsolute(httpRequest.Scheme, httpRequest.Host, httpRequest.PathBase); + // Handle forwarded headers directly if present + var scheme = httpRequest.Headers.GetCommaSeparatedValues("X-Forwarded-Proto") is [var forwardedScheme, ..] + ? forwardedScheme + : httpRequest.Scheme; + + var host = httpRequest.Headers.GetCommaSeparatedValues("X-Forwarded-Host") is [var forwardedHost, ..] + ? forwardedHost + : httpRequest.Host.Value; + + var hostString = new HostString(host); + var serverUrl = UriHelper.BuildAbsolute(scheme, hostString, httpRequest.PathBase); + return [new OpenApiServer { Url = serverUrl }]; } else @@ -207,6 +218,7 @@ internal List GetOpenApiServers(HttpRequest? httpRequest = null) return GetDevelopmentOpenApiServers(); } } + private List GetDevelopmentOpenApiServers() { if (hostEnvironment.IsDevelopment() && diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Servers.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Servers.cs index 1bc247c95ad4..40286a8e1c88 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Servers.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Servers.cs @@ -146,4 +146,47 @@ public void GetOpenApiServers_HandlesServerAddressFeatureWithNoValues() // Assert Assert.Empty(servers); } + + [Theory] + [InlineData("https", "proxy-server.com", "https://proxy-server.com/original-path")] + [InlineData("http", "proxy:8080", "http://proxy:8080/original-path")] + [InlineData("https", "proxy.example.org", "https://proxy.example.org/original-path")] + public void GetOpenApiServers_HandlesForwardedHeaders(string forwardedProto, string forwardedHost, string expectedUrl) + { + // Arrange + var hostEnvironment = new HostingEnvironment + { + ApplicationName = "TestApplication", + EnvironmentName = "Production" + }; + var docService = new OpenApiDocumentService( + "v1", + new Mock().Object, + hostEnvironment, + GetMockOptionsMonitor(), + new Mock().Object, + new OpenApiTestServer(["http://localhost:5000"])); + + var httpContext = new DefaultHttpContext() + { + Request = + { + // Original values that should be overridden by forwarded headers + Host = new HostString("localhost:5000"), + PathBase = "/original-path", + Scheme = "http" + } + }; + + // Add forwarded headers + httpContext.Request.Headers["X-Forwarded-Proto"] = forwardedProto; + httpContext.Request.Headers["X-Forwarded-Host"] = forwardedHost; + + // Act + var servers = docService.GetOpenApiServers(httpContext.Request); + + // Assert + var serverUrl = Assert.Single(servers).Url; + Assert.Equal(expectedUrl, serverUrl); + } } From 6cb8cdcc4c06a3f8890bb6fd61b93a9216d2e9f3 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Mon, 3 Mar 2025 21:20:07 -0800 Subject: [PATCH 3/3] Revert "Fix up handling for forwarded headers" This reverts commit bf991f64c789c0c7e4cbb650b44ff561afc6cbef. --- .../src/Services/OpenApiDocumentService.cs | 14 +----- .../OpenApiDocumentServiceTests.Servers.cs | 43 ------------------- 2 files changed, 1 insertion(+), 56 deletions(-) diff --git a/src/OpenApi/src/Services/OpenApiDocumentService.cs b/src/OpenApi/src/Services/OpenApiDocumentService.cs index d31a0443cf65..b907cc6ecb20 100644 --- a/src/OpenApi/src/Services/OpenApiDocumentService.cs +++ b/src/OpenApi/src/Services/OpenApiDocumentService.cs @@ -199,18 +199,7 @@ internal List GetOpenApiServers(HttpRequest? httpRequest = null) { if (httpRequest is not null) { - // Handle forwarded headers directly if present - var scheme = httpRequest.Headers.GetCommaSeparatedValues("X-Forwarded-Proto") is [var forwardedScheme, ..] - ? forwardedScheme - : httpRequest.Scheme; - - var host = httpRequest.Headers.GetCommaSeparatedValues("X-Forwarded-Host") is [var forwardedHost, ..] - ? forwardedHost - : httpRequest.Host.Value; - - var hostString = new HostString(host); - var serverUrl = UriHelper.BuildAbsolute(scheme, hostString, httpRequest.PathBase); - + var serverUrl = UriHelper.BuildAbsolute(httpRequest.Scheme, httpRequest.Host, httpRequest.PathBase); return [new OpenApiServer { Url = serverUrl }]; } else @@ -218,7 +207,6 @@ internal List GetOpenApiServers(HttpRequest? httpRequest = null) return GetDevelopmentOpenApiServers(); } } - private List GetDevelopmentOpenApiServers() { if (hostEnvironment.IsDevelopment() && diff --git a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Servers.cs b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Servers.cs index 40286a8e1c88..1bc247c95ad4 100644 --- a/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Servers.cs +++ b/src/OpenApi/test/Microsoft.AspNetCore.OpenApi.Tests/Services/OpenApiDocumentService/OpenApiDocumentServiceTests.Servers.cs @@ -146,47 +146,4 @@ public void GetOpenApiServers_HandlesServerAddressFeatureWithNoValues() // Assert Assert.Empty(servers); } - - [Theory] - [InlineData("https", "proxy-server.com", "https://proxy-server.com/original-path")] - [InlineData("http", "proxy:8080", "http://proxy:8080/original-path")] - [InlineData("https", "proxy.example.org", "https://proxy.example.org/original-path")] - public void GetOpenApiServers_HandlesForwardedHeaders(string forwardedProto, string forwardedHost, string expectedUrl) - { - // Arrange - var hostEnvironment = new HostingEnvironment - { - ApplicationName = "TestApplication", - EnvironmentName = "Production" - }; - var docService = new OpenApiDocumentService( - "v1", - new Mock().Object, - hostEnvironment, - GetMockOptionsMonitor(), - new Mock().Object, - new OpenApiTestServer(["http://localhost:5000"])); - - var httpContext = new DefaultHttpContext() - { - Request = - { - // Original values that should be overridden by forwarded headers - Host = new HostString("localhost:5000"), - PathBase = "/original-path", - Scheme = "http" - } - }; - - // Add forwarded headers - httpContext.Request.Headers["X-Forwarded-Proto"] = forwardedProto; - httpContext.Request.Headers["X-Forwarded-Host"] = forwardedHost; - - // Act - var servers = docService.GetOpenApiServers(httpContext.Request); - - // Assert - var serverUrl = Assert.Single(servers).Url; - Assert.Equal(expectedUrl, serverUrl); - } }