diff --git a/release_notes.md b/release_notes.md index 33b74629b8..d4d38a4eb7 100644 --- a/release_notes.md +++ b/release_notes.md @@ -16,3 +16,4 @@ - Microsoft.IdentityModel.JsonWebTokens - Microsoft.IdentityModel.Logging - Updated Grpc.AspNetCore package to 2.55.0 (https://github.com/Azure/azure-functions-host/pull/9373) +- Added RestoreRawRequestPath middleware to restore the raw un-decoded request path value to the current request (https://github.com/Azure/azure-functions-host/pull/9402) diff --git a/src/WebJobs.Script.WebHost/Middleware/RestoreRawRequestPathMiddleware.cs b/src/WebJobs.Script.WebHost/Middleware/RestoreRawRequestPathMiddleware.cs new file mode 100644 index 0000000000..9c3e255301 --- /dev/null +++ b/src/WebJobs.Script.WebHost/Middleware/RestoreRawRequestPathMiddleware.cs @@ -0,0 +1,40 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.Azure.WebJobs.Script.WebHost.Middleware +{ + /// + /// Middleware to restore the raw request URL path received, in the current request URL. + /// App service front end decodes encoded strings in the request URL path. + /// This causes routing to fail if the route param value has %2F in it. + /// Refer below issues for more context: + /// https://github.com/dotnet/aspnetcore/issues/40532#issuecomment-1083562919 + /// https://github.com/Azure/azure-functions-host/issues/9290. + /// + internal class RestoreRawRequestPathMiddleware + { + private readonly RequestDelegate _next; + internal const string UnEncodedUrlPathHeaderName = "X-Waws-Unencoded-Url"; + + public RestoreRawRequestPathMiddleware(RequestDelegate next) + { + _next = next ?? throw new ArgumentNullException(nameof(next)); + } + + public async Task Invoke(HttpContext context) + { + if (context.Request.Headers.TryGetValue(UnEncodedUrlPathHeaderName, out var unencodedUrlValue) && + unencodedUrlValue.Count > 0) + { + context.Request.Path = new PathString(unencodedUrlValue.First()); + } + + await _next(context); + } + } +} \ No newline at end of file diff --git a/src/WebJobs.Script.WebHost/WebJobsApplicationBuilderExtension.cs b/src/WebJobs.Script.WebHost/WebJobsApplicationBuilderExtension.cs index db7ff7763e..a1cc909bc3 100644 --- a/src/WebJobs.Script.WebHost/WebJobsApplicationBuilderExtension.cs +++ b/src/WebJobs.Script.WebHost/WebJobsApplicationBuilderExtension.cs @@ -35,6 +35,20 @@ public static IApplicationBuilder UseWebJobsScriptHost(this IApplicationBuilder // Ensure the ClrOptimizationMiddleware is registered before all middleware builder.UseMiddleware(); + + // Update the request URL path (which was altered by App service FE) to raw request path + // only if customer has opted in using the app setting. + builder.UseWhen( + _ => string.Equals( + environment.GetEnvironmentVariable(EnvironmentSettingNames.RestoreRawRequestPathEnabled), "1", + StringComparison.Ordinal), config => + { + config.UseMiddleware(); + // We need to re-add routing middleware after any updates to request path. + config.UseRouting(); + config.UseEndpoints(endpoints => endpoints.MapControllers()); + }); + builder.UseMiddleware(); builder.UseMiddleware(); builder.UseMiddleware(); diff --git a/src/WebJobs.Script/Environment/EnvironmentSettingNames.cs b/src/WebJobs.Script/Environment/EnvironmentSettingNames.cs index 4d7d416932..d40d0bc636 100644 --- a/src/WebJobs.Script/Environment/EnvironmentSettingNames.cs +++ b/src/WebJobs.Script/Environment/EnvironmentSettingNames.cs @@ -138,5 +138,8 @@ public static class EnvironmentSettingNames public const string AppKind = "APP_KIND"; public const string DrainOnApplicationStopping = "FUNCTIONS_ENABLE_DRAIN_ON_APP_STOPPING"; + + // Restore the raw request path from wire to the current request. + public const string RestoreRawRequestPathEnabled = "WEBSITE_RESTORE_RAW_REQUEST_PATH"; } } diff --git a/test/WebJobs.Script.Tests/Middleware/RestoreRawRequestPathMiddlewareTests.cs b/test/WebJobs.Script.Tests/Middleware/RestoreRawRequestPathMiddlewareTests.cs new file mode 100644 index 0000000000..c719758d67 --- /dev/null +++ b/test/WebJobs.Script.Tests/Middleware/RestoreRawRequestPathMiddlewareTests.cs @@ -0,0 +1,67 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Azure.WebJobs.Script.WebHost.Middleware; +using Moq; +using Xunit; + +public class RestoreRawRequestPathMiddlewareTests +{ + [Fact] + public async Task Invoke_WithUnencodedHeaderValue_SetsRequestPath() + { + // Arrange + const string unEncodedHeaderValue = "/cloud%2Fdevdiv"; + const string requestPathValue = "/cloud/devdiv"; + var context = CreateHttpContextWithHeader(requestPathValue, unEncodedHeaderValue); + var mockNext = new Mock(); + var middleware = new RestoreRawRequestPathMiddleware(mockNext.Object); + var originalPath = context.Request.Path; + + // Act + await middleware.Invoke(context); + + // Assert + Assert.NotEqual(new PathString(originalPath), context.Request.Path); + Assert.Equal(new PathString(unEncodedHeaderValue), context.Request.Path); + mockNext.Verify(next => next(context), Times.Once()); + } + + [Fact] + public async Task Invoke_WithoutUnencodedUrlHeader_DoesNotChangeRequestPath() + { + // Arrange + const string requestPathValue = "/cloud/devdiv"; + var context = CreateHttpContextWithHeader(requestPathValue); + var mockNext = new Mock(); + var middleware = new RestoreRawRequestPathMiddleware(mockNext.Object); + var originalPath = context.Request.Path; + + // Act + await middleware.Invoke(context); + + // Assert + Assert.Equal(originalPath, context.Request.Path); + mockNext.Verify(next => next(context), Times.Once()); + } + + private static HttpContext CreateHttpContextWithHeader(string requestPathValue, string unEncodedHeaderValue = null) + { + var context = new DefaultHttpContext + { + Request = + { + Path = requestPathValue + } + }; + + if (unEncodedHeaderValue is not null) + { + context.Request.Headers[RestoreRawRequestPathMiddleware.UnEncodedUrlPathHeaderName] = unEncodedHeaderValue; + } + + return context; + } +}