Skip to content

Commit

Permalink
Observe the route template constraints in the Swagger middleware
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
0xced committed Apr 13, 2024
1 parent 9e77996 commit 94b2ef7
Show file tree
Hide file tree
Showing 11 changed files with 166 additions and 12 deletions.
17 changes: 16 additions & 1 deletion Swashbuckle.AspNetCore.sln
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@


Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31717.71
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SwaggerEndpointOptions> setupAction = null)
{
if (!RoutePatternFactory.Parse(pattern).Parameters.Any(x => x.Name == "documentName"))
Expand Down
48 changes: 41 additions & 7 deletions src/Swashbuckle.AspNetCore.Swagger/SwaggerMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -57,7 +70,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);
}
Expand All @@ -72,16 +85,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)
Expand Down
2 changes: 1 addition & 1 deletion src/Swashbuckle.AspNetCore.Swagger/SwaggerOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public SwaggerOptions()
/// <summary>
/// Sets a custom route for the Swagger JSON/YAML endpoint(s). Must include the {documentName} parameter
/// </summary>
public string RouteTemplate { get; set; } = "swagger/{documentName}/swagger.{json|yaml}";
public string RouteTemplate { get; set; } = "swagger/{documentName}/swagger.{extension:regex(^(json|ya?ml)$)}";

/// <summary>
/// Return Swagger JSON/YAML in the V2 format rather than V3
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TopLevelSwaggerDoc.Program>().CreateClient();

var response = await client.GetAsync(path);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(mediaType, response.Content.Headers.ContentType?.MediaType);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<AssemblyOriginatorKeyFile>Swashbuckle.AspNetCore.IntegrationTests.snk</AssemblyOriginatorKeyFile>
Expand All @@ -19,10 +19,11 @@
<ProjectReference Include="..\WebSites\OAuth2Integration\OAuth2Integration.csproj" />
<ProjectReference Include="..\WebSites\ReDoc\ReDoc.csproj" />
<ProjectReference Include="..\WebSites\TestFirst\TestFirst.csproj" />
<ProjectReference Include="..\WebSites\TopLevelSwaggerDoc\TopLevelSwaggerDoc.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.OpenApi.Readers" />
<PackageReference Include="xunit" />
Expand Down
19 changes: 19 additions & 0 deletions test/WebSites/TopLevelSwaggerDoc/Program.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
28 changes: 28 additions & 0 deletions test/WebSites/TopLevelSwaggerDoc/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
15 changes: 15 additions & 0 deletions test/WebSites/TopLevelSwaggerDoc/TopLevelSwaggerDoc.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\src\Swashbuckle.AspNetCore.Swagger\Swashbuckle.AspNetCore.Swagger.csproj" />
<ProjectReference Include="..\..\..\src\Swashbuckle.AspNetCore.SwaggerGen\Swashbuckle.AspNetCore.SwaggerGen.csproj" />
<ProjectReference Include="..\..\..\src\Swashbuckle.AspNetCore.SwaggerUI\Swashbuckle.AspNetCore.SwaggerUI.csproj" />
</ItemGroup>

</Project>
8 changes: 8 additions & 0 deletions test/WebSites/TopLevelSwaggerDoc/appsettings.Development.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
9 changes: 9 additions & 0 deletions test/WebSites/TopLevelSwaggerDoc/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

0 comments on commit 94b2ef7

Please sign in to comment.