From f26510da7c68e9b8e408541e344a6fbbf49b0da6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Luthi?= Date: Thu, 18 Apr 2024 18:58:09 +0200 Subject: [PATCH] Observe the route template constraints in the Swagger middleware (#2418) The default route template, i.e. `swagger/{documentName}/swagger.{json|yaml}` which is used by the `SwaggerMiddleware` is problematic because it matches any file extension. Even though it *looks like* only `json` and `yaml` extensions are supported, actually **any** extension matches. Trying to hit the following endpoints all return the JSON swagger document: * `swagger/v1/swagger.xml` * `swagger/v1/swagger.yml` * `swagger/v1/swagger.anything` This is not a very big deal, until the `SwaggerUIMiddleware` is also used and one chooses to modify the default route to `swagger/{documentName}.{json|yaml}`. This is the problematic configuration: ```csharp var builder = WebApplication.CreateBuilder(args); builder.Services.AddMvcCore().AddApiExplorer(); builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1", new OpenApiInfo { Title = "Test API", Version = "1" })); var app = builder.Build(); app.UseSwagger(c => c.RouteTemplate = "swagger/{documentName}.{json|yaml}"); app.UseSwaggerUI(c => c.SwaggerEndpoint("v1.json", "Test API")); app.Run(); ``` At this point, the `SwaggerMiddleware` will try to serve `swagger/index.html` because the route template matches (`documentName` = `index` and `json|yaml` = `html`) but the `index` document doesn't exist and this results in a 404 instead of calling the next (SwaggerUI) middleware. To fix this issue, the default route template has been modified to `swagger/{documentName}/swagger.{extension:regex(^(json|ya?ml)$)}`, leveraging ASP.NET Core [route constraints](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/routing?view=aspnetcore-6.0#route-constraints) and the constraints are actually enforced in the `SwaggerMiddleware` implementation. The default route template has also been modified in the `MapSwagger` method to ensure that only `json`, `yaml` and `yml` extensions are supported by default. --- Swashbuckle.AspNetCore.sln | 17 ++++++- .../SwaggerBuilderExtensions.cs | 15 ++++-- .../SwaggerMiddleware.cs | 49 ++++++++++++++++--- .../SwaggerOptions.cs | 4 +- .../SwaggerAndSwaggerUIIntegrationTests.cs | 25 ++++++++++ ...hbuckle.AspNetCore.IntegrationTests.csproj | 5 +- test/WebSites/TopLevelSwaggerDoc/Program.cs | 19 +++++++ .../Properties/launchSettings.json | 28 +++++++++++ .../TopLevelSwaggerDoc.csproj | 15 ++++++ .../appsettings.Development.json | 8 +++ .../TopLevelSwaggerDoc/appsettings.json | 9 ++++ 11 files changed, 179 insertions(+), 15 deletions(-) create mode 100644 test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerAndSwaggerUIIntegrationTests.cs create mode 100644 test/WebSites/TopLevelSwaggerDoc/Program.cs create mode 100644 test/WebSites/TopLevelSwaggerDoc/Properties/launchSettings.json create mode 100644 test/WebSites/TopLevelSwaggerDoc/TopLevelSwaggerDoc.csproj create mode 100644 test/WebSites/TopLevelSwaggerDoc/appsettings.Development.json create mode 100644 test/WebSites/TopLevelSwaggerDoc/appsettings.json diff --git a/Swashbuckle.AspNetCore.sln b/Swashbuckle.AspNetCore.sln index d310bb0f8b..0179a8b1c5 100644 --- a/Swashbuckle.AspNetCore.sln +++ b/Swashbuckle.AspNetCore.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31717.71 @@ -91,6 +91,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Swashbuckle.AspNetCore.Test EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MinimalApp", "test\WebSites\MinimalApp\MinimalApp.csproj", "{3D0126CB-5439-483C-B2D5-4B4BE111D15C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TopLevelSwaggerDoc", "test\WebSites\TopLevelSwaggerDoc\TopLevelSwaggerDoc.csproj", "{6EA75DA8-9B1F-468E-9425-37F01A129B0F}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{3BA087DA-788C-43D6-9D8B-1EF017014A4A}" ProjectSection(SolutionItems) = preProject .github\actionlint-matcher.json = .github\actionlint-matcher.json @@ -501,6 +503,18 @@ Global {3D0126CB-5439-483C-B2D5-4B4BE111D15C}.Release|x64.Build.0 = Release|Any CPU {3D0126CB-5439-483C-B2D5-4B4BE111D15C}.Release|x86.ActiveCfg = Release|Any CPU {3D0126CB-5439-483C-B2D5-4B4BE111D15C}.Release|x86.Build.0 = Release|Any CPU + {6EA75DA8-9B1F-468E-9425-37F01A129B0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6EA75DA8-9B1F-468E-9425-37F01A129B0F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6EA75DA8-9B1F-468E-9425-37F01A129B0F}.Debug|x64.ActiveCfg = Debug|Any CPU + {6EA75DA8-9B1F-468E-9425-37F01A129B0F}.Debug|x64.Build.0 = Debug|Any CPU + {6EA75DA8-9B1F-468E-9425-37F01A129B0F}.Debug|x86.ActiveCfg = Debug|Any CPU + {6EA75DA8-9B1F-468E-9425-37F01A129B0F}.Debug|x86.Build.0 = Debug|Any CPU + {6EA75DA8-9B1F-468E-9425-37F01A129B0F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6EA75DA8-9B1F-468E-9425-37F01A129B0F}.Release|Any CPU.Build.0 = Release|Any CPU + {6EA75DA8-9B1F-468E-9425-37F01A129B0F}.Release|x64.ActiveCfg = Release|Any CPU + {6EA75DA8-9B1F-468E-9425-37F01A129B0F}.Release|x64.Build.0 = Release|Any CPU + {6EA75DA8-9B1F-468E-9425-37F01A129B0F}.Release|x86.ActiveCfg = Release|Any CPU + {6EA75DA8-9B1F-468E-9425-37F01A129B0F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -539,6 +553,7 @@ Global {76692D68-C38C-4A7D-B3DA-DA78A393E266} = {0ADCB223-F375-45AB-8BC4-834EC9C69554} {66590FBA-5FDD-4AC9-AF91-26ADAB33CCB8} = {0ADCB223-F375-45AB-8BC4-834EC9C69554} {3D0126CB-5439-483C-B2D5-4B4BE111D15C} = {DB3F57FC-1472-4F03-B551-43394DA3C5EB} + {6EA75DA8-9B1F-468E-9425-37F01A129B0F} = {DB3F57FC-1472-4F03-B551-43394DA3C5EB} {3BA087DA-788C-43D6-9D8B-1EF017014A4A} = {FA1B4021-0A97-4F68-B966-148191F6AAA8} {A0EC16BE-C520-4FCF-BB54-2D79CD255F00} = {3BA087DA-788C-43D6-9D8B-1EF017014A4A} EndGlobalSection diff --git a/src/Swashbuckle.AspNetCore.Swagger/DependencyInjection/SwaggerBuilderExtensions.cs b/src/Swashbuckle.AspNetCore.Swagger/DependencyInjection/SwaggerBuilderExtensions.cs index d2cca9492f..be1f3945a2 100644 --- a/src/Swashbuckle.AspNetCore.Swagger/DependencyInjection/SwaggerBuilderExtensions.cs +++ b/src/Swashbuckle.AspNetCore.Swagger/DependencyInjection/SwaggerBuilderExtensions.cs @@ -1,9 +1,10 @@ -using System; -using System.Linq; -using Microsoft.AspNetCore.Routing; +using System; #if (!NETSTANDARD2_0) +using System.Linq; +using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.AspNetCore.Routing.Template; #endif using Microsoft.Extensions.DependencyInjection; @@ -19,7 +20,11 @@ public static class SwaggerBuilderExtensions /// public static IApplicationBuilder UseSwagger(this IApplicationBuilder app, SwaggerOptions options) { +#if (!NETSTANDARD2_0) + return app.UseMiddleware(options, app.ApplicationServices.GetRequiredService()); +#else return app.UseMiddleware(options); +#endif } /// @@ -42,7 +47,7 @@ public static IApplicationBuilder UseSwagger( #if (!NETSTANDARD2_0) public static IEndpointConventionBuilder MapSwagger( this IEndpointRouteBuilder endpoints, - string pattern = "/swagger/{documentName}/swagger.{json|yaml}", + string pattern = SwaggerOptions.DefaultRouteTemplate, Action setupAction = null) { if (!RoutePatternFactory.Parse(pattern).Parameters.Any(x => x.Name == "documentName")) @@ -69,4 +74,4 @@ public static IEndpointConventionBuilder MapSwagger( } #endif } -} \ No newline at end of file +} diff --git a/src/Swashbuckle.AspNetCore.Swagger/SwaggerMiddleware.cs b/src/Swashbuckle.AspNetCore.Swagger/SwaggerMiddleware.cs index cb6b21413f..e94802806e 100644 --- a/src/Swashbuckle.AspNetCore.Swagger/SwaggerMiddleware.cs +++ b/src/Swashbuckle.AspNetCore.Swagger/SwaggerMiddleware.cs @@ -4,6 +4,9 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +#if !NETSTANDARD +using Microsoft.AspNetCore.Routing.Patterns; +#endif using Microsoft.AspNetCore.Routing.Template; using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Writers; @@ -15,6 +18,9 @@ public class SwaggerMiddleware private readonly RequestDelegate _next; private readonly SwaggerOptions _options; private readonly TemplateMatcher _requestMatcher; +#if !NETSTANDARD + private readonly TemplateBinder _templateBinder; +#endif public SwaggerMiddleware( RequestDelegate next, @@ -25,9 +31,19 @@ public SwaggerMiddleware( _requestMatcher = new TemplateMatcher(TemplateParser.Parse(_options.RouteTemplate), new RouteValueDictionary()); } +#if !NETSTANDARD + public SwaggerMiddleware( + RequestDelegate next, + SwaggerOptions options, + TemplateBinderFactory templateBinderFactory) : this(next, options) + { + _templateBinder = templateBinderFactory.Create(RoutePatternFactory.Parse(_options.RouteTemplate)); + } +#endif + public async Task Invoke(HttpContext httpContext, ISwaggerProvider swaggerProvider) { - if (!RequestingSwaggerDocument(httpContext.Request, out string documentName)) + if (!RequestingSwaggerDocument(httpContext.Request, out string documentName, out string extension)) { await _next(httpContext); return; @@ -57,7 +73,7 @@ public async Task Invoke(HttpContext httpContext, ISwaggerProvider swaggerProvid filter(swagger, httpContext.Request); } - if (Path.GetExtension(httpContext.Request.Path.Value) == ".yaml") + if (extension is ".yaml" or ".yml") { await RespondWithSwaggerYaml(httpContext.Response, swagger); } @@ -72,16 +88,37 @@ public async Task Invoke(HttpContext httpContext, ISwaggerProvider swaggerProvid } } - private bool RequestingSwaggerDocument(HttpRequest request, out string documentName) + private bool RequestingSwaggerDocument(HttpRequest request, out string documentName, out string extension) { documentName = null; + extension = null; if (request.Method != "GET") return false; var routeValues = new RouteValueDictionary(); - if (!_requestMatcher.TryMatch(request.Path, routeValues) || !routeValues.ContainsKey("documentName")) return false; + if (_requestMatcher.TryMatch(request.Path, routeValues)) + { +#if !NETSTANDARD + if (_templateBinder != null && !_templateBinder.TryProcessConstraints(request.HttpContext, routeValues, out _, out _)) + { + return false; + } +#endif + if (routeValues.TryGetValue("documentName", out var documentNameObject) && documentNameObject is string documentNameString) + { + documentName = documentNameString; + if (routeValues.TryGetValue("extension", out var extensionObject)) + { + extension = $".{extensionObject}"; + } + else + { + extension = Path.GetExtension(request.Path.Value); + } + return true; + } + } - documentName = routeValues["documentName"].ToString(); - return true; + return false; } private void RespondWithNotFound(HttpResponse response) diff --git a/src/Swashbuckle.AspNetCore.Swagger/SwaggerOptions.cs b/src/Swashbuckle.AspNetCore.Swagger/SwaggerOptions.cs index 582e85d7c3..4cb89816f4 100644 --- a/src/Swashbuckle.AspNetCore.Swagger/SwaggerOptions.cs +++ b/src/Swashbuckle.AspNetCore.Swagger/SwaggerOptions.cs @@ -7,6 +7,8 @@ namespace Swashbuckle.AspNetCore.Swagger { public class SwaggerOptions { + internal const string DefaultRouteTemplate = "/swagger/{documentName}/swagger.{extension:regex(^(json|ya?ml)$)}"; + public SwaggerOptions() { PreSerializeFilters = new List>(); @@ -16,7 +18,7 @@ public SwaggerOptions() /// /// Sets a custom route for the Swagger JSON/YAML endpoint(s). Must include the {documentName} parameter /// - public string RouteTemplate { get; set; } = "swagger/{documentName}/swagger.{json|yaml}"; + public string RouteTemplate { get; set; } = DefaultRouteTemplate; /// /// Return Swagger JSON/YAML in the V2 format rather than V3 diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerAndSwaggerUIIntegrationTests.cs b/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerAndSwaggerUIIntegrationTests.cs new file mode 100644 index 0000000000..41d1192ac2 --- /dev/null +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/SwaggerAndSwaggerUIIntegrationTests.cs @@ -0,0 +1,25 @@ +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace Swashbuckle.AspNetCore.IntegrationTests +{ + public class SwaggerAndSwaggerUIIntegrationTests + { + [Theory] + [InlineData("/swagger/index.html", "text/html")] + [InlineData("/swagger/v1.json", "application/json")] + [InlineData("/swagger/v1.yaml", "text/yaml")] + [InlineData("/swagger/v1.yml", "text/yaml")] + public async Task SwaggerDocWithoutSubdirectory(string path, string mediaType) + { + var client = new WebApplicationFactory().CreateClient(); + + var response = await client.GetAsync(path); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(mediaType, response.Content.Headers.ContentType?.MediaType); + } + } +} \ No newline at end of file diff --git a/test/Swashbuckle.AspNetCore.IntegrationTests/Swashbuckle.AspNetCore.IntegrationTests.csproj b/test/Swashbuckle.AspNetCore.IntegrationTests/Swashbuckle.AspNetCore.IntegrationTests.csproj index e19e5cf393..87a99d69b8 100644 --- a/test/Swashbuckle.AspNetCore.IntegrationTests/Swashbuckle.AspNetCore.IntegrationTests.csproj +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/Swashbuckle.AspNetCore.IntegrationTests.csproj @@ -1,4 +1,4 @@ - + Swashbuckle.AspNetCore.IntegrationTests.snk @@ -19,10 +19,11 @@ + - + diff --git a/test/WebSites/TopLevelSwaggerDoc/Program.cs b/test/WebSites/TopLevelSwaggerDoc/Program.cs new file mode 100644 index 0000000000..df5c18e61b --- /dev/null +++ b/test/WebSites/TopLevelSwaggerDoc/Program.cs @@ -0,0 +1,19 @@ +using Microsoft.OpenApi.Models; + +namespace TopLevelSwaggerDoc; + +public class Program +{ + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + builder.Services.AddMvcCore().AddApiExplorer(); + builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1", new OpenApiInfo { Title = "Test API", Version = "1" })); + var app = builder.Build(); + + app.UseSwagger(c => c.RouteTemplate = c.RouteTemplate.Replace("swagger/{documentName}/swagger.", "swagger/{documentName}.")); + app.UseSwaggerUI(c => c.SwaggerEndpoint("v1.json", "Test API")); + + app.Run(); + } +} diff --git a/test/WebSites/TopLevelSwaggerDoc/Properties/launchSettings.json b/test/WebSites/TopLevelSwaggerDoc/Properties/launchSettings.json new file mode 100644 index 0000000000..9c65e748d5 --- /dev/null +++ b/test/WebSites/TopLevelSwaggerDoc/Properties/launchSettings.json @@ -0,0 +1,28 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:12841", + "sslPort": 44316 + } + }, + "profiles": { + "TopLevelSwaggerDoc": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7028;http://localhost:5225", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/test/WebSites/TopLevelSwaggerDoc/TopLevelSwaggerDoc.csproj b/test/WebSites/TopLevelSwaggerDoc/TopLevelSwaggerDoc.csproj new file mode 100644 index 0000000000..920653e1ba --- /dev/null +++ b/test/WebSites/TopLevelSwaggerDoc/TopLevelSwaggerDoc.csproj @@ -0,0 +1,15 @@ + + + + net6.0 + enable + enable + + + + + + + + + diff --git a/test/WebSites/TopLevelSwaggerDoc/appsettings.Development.json b/test/WebSites/TopLevelSwaggerDoc/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/test/WebSites/TopLevelSwaggerDoc/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/test/WebSites/TopLevelSwaggerDoc/appsettings.json b/test/WebSites/TopLevelSwaggerDoc/appsettings.json new file mode 100644 index 0000000000..10f68b8c8b --- /dev/null +++ b/test/WebSites/TopLevelSwaggerDoc/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}