From 7d0b067b1880395a94ea162b475c35a30a338f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Luthi?= Date: Wed, 18 May 2022 09:22:56 +0200 Subject: [PATCH] Observe the route template constraints in the Swagger middleware 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 | 15 ++++++ .../SwaggerBuilderExtensions.cs | 2 +- .../SwaggerMiddleware.cs | 48 ++++++++++++++++--- .../SwaggerOptions.cs | 2 +- .../SwaggerAndSwaggerUIIntegrationTests.cs | 25 ++++++++++ ...hbuckle.AspNetCore.IntegrationTests.csproj | 3 +- test/WebSites/TopLevelSwaggerDoc/Program.cs | 19 ++++++++ .../Properties/launchSettings.json | 28 +++++++++++ .../TopLevelSwaggerDoc.csproj | 15 ++++++ .../appsettings.Development.json | 8 ++++ .../TopLevelSwaggerDoc/appsettings.json | 9 ++++ 11 files changed, 164 insertions(+), 10 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 083033fadb..443ea35095 100644 --- a/Swashbuckle.AspNetCore.sln +++ b/Swashbuckle.AspNetCore.sln @@ -92,6 +92,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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -486,6 +488,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 @@ -524,6 +538,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} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {36FC6A67-247D-4149-8EDD-79FFD1A75F51} diff --git a/src/Swashbuckle.AspNetCore.Swagger/DependencyInjection/SwaggerBuilderExtensions.cs b/src/Swashbuckle.AspNetCore.Swagger/DependencyInjection/SwaggerBuilderExtensions.cs index d2cca9492f..1f4aa3ebeb 100644 --- a/src/Swashbuckle.AspNetCore.Swagger/DependencyInjection/SwaggerBuilderExtensions.cs +++ b/src/Swashbuckle.AspNetCore.Swagger/DependencyInjection/SwaggerBuilderExtensions.cs @@ -42,7 +42,7 @@ public static IApplicationBuilder UseSwagger( #if (!NETSTANDARD2_0) public static IEndpointConventionBuilder MapSwagger( this IEndpointRouteBuilder endpoints, - string pattern = "/swagger/{documentName}/swagger.{json|yaml}", + string pattern = "/swagger/{documentName}/swagger.{extension:regex(^(json|ya?ml)$)}", Action setupAction = null) { if (!RoutePatternFactory.Parse(pattern).Parameters.Any(x => x.Name == "documentName")) diff --git a/src/Swashbuckle.AspNetCore.Swagger/SwaggerMiddleware.cs b/src/Swashbuckle.AspNetCore.Swagger/SwaggerMiddleware.cs index e44a202aa8..96556f1271 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,19 +18,29 @@ 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, - SwaggerOptions options) + SwaggerOptions options +#if !NETSTANDARD + ,TemplateBinderFactory templateBinderFactory +#endif + ) { _next = next; _options = options ?? new SwaggerOptions(); _requestMatcher = new TemplateMatcher(TemplateParser.Parse(_options.RouteTemplate), new RouteValueDictionary()); +#if !NETSTANDARD + _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; @@ -50,7 +63,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); } @@ -65,16 +78,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.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..1ff6ce53e7 100644 --- a/src/Swashbuckle.AspNetCore.Swagger/SwaggerOptions.cs +++ b/src/Swashbuckle.AspNetCore.Swagger/SwaggerOptions.cs @@ -16,7 +16,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; } = "swagger/{documentName}/swagger.{extension:regex(^(json|ya?ml)$)}"; /// /// 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 11cf27e218..51096477aa 100644 --- a/test/Swashbuckle.AspNetCore.IntegrationTests/Swashbuckle.AspNetCore.IntegrationTests.csproj +++ b/test/Swashbuckle.AspNetCore.IntegrationTests/Swashbuckle.AspNetCore.IntegrationTests.csproj @@ -19,6 +19,7 @@ + @@ -29,7 +30,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + 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": "*" +}