From 2b0e7f81edf02051b86cd9ccc543da12011c4576 Mon Sep 17 00:00:00 2001 From: Mike Kistler Date: Sun, 15 Feb 2026 08:52:46 -0600 Subject: [PATCH 1/2] Add UrlDecode option to FromRouteAttribute for full percent-decoding Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Metadata/IFromRouteMetadata.cs | 19 +++++ .../src/PublicAPI.Unshipped.txt | 1 + .../Emitters/EndpointParameterEmitter.cs | 9 ++ .../EndpointParameter.cs | 5 +- .../src/RequestDelegateFactory.cs | 25 +++++- .../test/RequestDelegateFactoryTests.cs | 80 ++++++++++++++++++ ...estDelegateCreationTests.RouteParameter.cs | 45 ++++++++++ src/Mvc/Mvc.Core/src/FromRouteAttribute.cs | 15 ++++ .../Binders/SimpleTypeModelBinder.cs | 38 +++++++++ src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt | 2 + .../Binders/SimpleTypeModelBinderTest.cs | 82 +++++++++++++++++++ 11 files changed, 318 insertions(+), 3 deletions(-) diff --git a/src/Http/Http.Abstractions/src/Metadata/IFromRouteMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IFromRouteMetadata.cs index ab53cbd77c89..9a27107f22c8 100644 --- a/src/Http/Http.Abstractions/src/Metadata/IFromRouteMetadata.cs +++ b/src/Http/Http.Abstractions/src/Metadata/IFromRouteMetadata.cs @@ -12,4 +12,23 @@ public interface IFromRouteMetadata /// The name. /// string? Name { get; } + + /// + /// Gets a value indicating whether the route parameter value should be fully URL-decoded + /// using . + /// + /// + /// + /// By default, some characters such as / (%2F) are not decoded + /// in route values because they are decoded at the server level with special handling. + /// Setting this property to true ensures the value is fully percent-decoded + /// per RFC 3986. + /// + /// + /// + /// app.MapGet("/users/{userId}", ([FromRoute(UrlDecode = true)] string userId) => userId); + /// + /// + /// + bool UrlDecode => false; } diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..229fa5d8fdb7 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +Microsoft.AspNetCore.Http.Metadata.IFromRouteMetadata.UrlDecode.get -> bool diff --git a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/Emitters/EndpointParameterEmitter.cs b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/Emitters/EndpointParameterEmitter.cs index 399c61755280..f81226faf9ba 100644 --- a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/Emitters/EndpointParameterEmitter.cs +++ b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/Emitters/EndpointParameterEmitter.cs @@ -204,6 +204,15 @@ internal static void EmitRouteParameterPreparation(this EndpointParameter endpoi var assigningCode = $"(string?)httpContext.Request.RouteValues[\"{endpointParameter.LookupName}\"]"; codeWriter.WriteLine($"var {endpointParameter.EmitAssigningCodeResult()} = {assigningCode};"); + // Apply Uri.UnescapeDataString to fully decode percent-encoded route values (e.g. %2F → /) + if (endpointParameter.UrlDecode) + { + codeWriter.WriteLine($"if ({endpointParameter.EmitAssigningCodeResult()} is not null)"); + codeWriter.StartBlock(); + codeWriter.WriteLine($"{endpointParameter.EmitAssigningCodeResult()} = Uri.UnescapeDataString({endpointParameter.EmitAssigningCodeResult()});"); + codeWriter.EndBlock(); + } + if (!endpointParameter.IsOptional) { codeWriter.WriteLine($"if ({endpointParameter.EmitAssigningCodeResult()} == null)"); diff --git a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/EndpointParameter.cs b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/EndpointParameter.cs index 80ee927a64f4..1a1a9fd24d2a 100644 --- a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/EndpointParameter.cs +++ b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/EndpointParameter.cs @@ -73,6 +73,7 @@ private void ProcessEndpointParameterSource(Endpoint endpoint, ISymbol symbol, I LookupName = GetEscapedParameterName(fromRouteAttribute, symbol.Name); IsParsable = TryGetParsability(Type, wellKnownTypes, out var preferredTryParseInvocation); PreferredTryParseInvocation = preferredTryParseInvocation; + UrlDecode = fromRouteAttribute.TryGetNamedArgumentValue("UrlDecode", out var urlDecode) && urlDecode; } else if (attributes.TryGetAttributeImplementingInterface(wellKnownTypes.Get(WellKnownType.Microsoft_AspNetCore_Http_Metadata_IFromQueryMetadata), out var fromQueryAttribute)) { @@ -297,6 +298,7 @@ private static bool ImplementsIEndpointParameterMetadataProvider(ITypeSymbol typ public bool IsParsable { get; set; } public Func? PreferredTryParseInvocation { get; set; } public bool IsStringValues { get; set; } + public bool UrlDecode { get; set; } public BindabilityMethod? BindMethod { get; set; } public IMethodSymbol? BindableMethodSymbol { get; set; } @@ -599,7 +601,8 @@ obj is EndpointParameter other && other.Ordinal == Ordinal && other.IsOptional == IsOptional && SymbolEqualityComparer.IncludeNullability.Equals(other.Type, Type) && - other.KeyedServiceKey == KeyedServiceKey; + other.KeyedServiceKey == KeyedServiceKey && + other.UrlDecode == UrlDecode; public bool SignatureEquals(object obj) => obj is EndpointParameter other && diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs index c51e7aae5a36..ac4b8de3034a 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs @@ -60,6 +60,7 @@ public static partial class RequestDelegateFactory private static readonly MethodInfo ResultWriteResponseAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteResultWriteResponse), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo StringResultWriteResponseAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteWriteStringResponseAsync), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo StringIsNullOrEmptyMethod = typeof(string).GetMethod(nameof(string.IsNullOrEmpty), BindingFlags.Static | BindingFlags.Public)!; + private static readonly MethodInfo UriUnescapeDataStringMethod = typeof(Uri).GetMethod(nameof(Uri.UnescapeDataString), BindingFlags.Static | BindingFlags.Public, new[] { typeof(string) })!; private static readonly MethodInfo WrapObjectAsValueTaskMethod = typeof(RequestDelegateFactory).GetMethod(nameof(WrapObjectAsValueTask), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo TaskOfTToValueTaskOfObjectMethod = typeof(RequestDelegateFactory).GetMethod(nameof(TaskOfTToValueTaskOfObject), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo ValueTaskOfTToValueTaskOfObjectMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ValueTaskOfTToValueTaskOfObject), BindingFlags.NonPublic | BindingFlags.Static)!; @@ -734,7 +735,7 @@ private static Expression CreateArgument(ParameterInfo parameter, RequestDelegat throw new InvalidOperationException($"'{routeName}' is not a route parameter."); } - return BindParameterFromProperty(parameter, RouteValuesExpr, RouteValuesIndexerProperty, routeName, factoryContext, "route"); + return BindParameterFromProperty(parameter, RouteValuesExpr, RouteValuesIndexerProperty, routeName, factoryContext, "route", urlDecode: routeAttribute.UrlDecode); } else if (parameterCustomAttributes.OfType().FirstOrDefault() is { } queryAttribute) { @@ -1568,6 +1569,20 @@ private static Expression GetValueFromProperty(MemberExpression sourceExpression return Expression.Convert(indexExpression, returnType ?? typeof(string)); } + // Wraps a string expression with null-safe Uri.UnescapeDataString: value is null ? null : Uri.UnescapeDataString(value) + private static Expression ApplyUrlDecode(Expression valueExpression) + { + var tempVar = Expression.Variable(typeof(string), "routeValue"); + return Expression.Block( + typeof(string), + new[] { tempVar }, + Expression.Assign(tempVar, valueExpression), + Expression.Condition( + Expression.Equal(tempVar, Expression.Constant(null, typeof(string))), + Expression.Constant(null, typeof(string)), + Expression.Call(UriUnescapeDataStringMethod, tempVar))); + } + private static Expression BindParameterFromProperties(ParameterInfo parameter, RequestDelegateFactoryContext factoryContext) { var parameterType = parameter.ParameterType; @@ -1967,12 +1982,18 @@ private static Expression BindParameterFromExpression( Expression.Convert(CreateDefaultValueExpression(parameter.DefaultValue, parameter.ParameterType), parameter.ParameterType))); } - private static Expression BindParameterFromProperty(ParameterInfo parameter, MemberExpression property, PropertyInfo itemProperty, string key, RequestDelegateFactoryContext factoryContext, string source) + private static Expression BindParameterFromProperty(ParameterInfo parameter, MemberExpression property, PropertyInfo itemProperty, string key, RequestDelegateFactoryContext factoryContext, string source, bool urlDecode = false) { var valueExpression = (source == "header" && parameter.ParameterType.IsArray) ? Expression.Call(GetHeaderSplitMethod, property, Expression.Constant(key)) : GetValueFromProperty(property, itemProperty, key, GetExpressionType(parameter.ParameterType)); + // Apply Uri.UnescapeDataString to fully decode percent-encoded route values (e.g. %2F → /) + if (urlDecode) + { + valueExpression = ApplyUrlDecode(valueExpression); + } + return BindParameterFromValue(parameter, valueExpression, factoryContext, source); } diff --git a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs index 61e7d2a46965..0545d50a4d68 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs @@ -293,6 +293,85 @@ public void SpecifiedEmptyRouteParametersThrowIfRouteParameterDoesNotExist() Assert.Equal("'id' is not a route parameter.", ex.Message); } + [Fact] + public async Task FromRouteWithUrlDecodeTrueDecodesPercentEncodedValues() + { + var httpContext = CreateHttpContext(); + var responseBodyStream = new MemoryStream(); + httpContext.Response.Body = responseBodyStream; + + // %2F is preserved by the server's path decoder, so route values contain literal "%2F" + httpContext.Request.RouteValues["userId"] = "domain%2Fuser"; + + var factoryResult = RequestDelegateFactory.Create( + ([FromRoute(UrlDecode = true)] string userId) => userId, + new() { RouteParameterNames = new[] { "userId" } }); + + await factoryResult.RequestDelegate(httpContext); + + Assert.Equal(200, httpContext.Response.StatusCode); + var body = Encoding.UTF8.GetString(responseBodyStream.ToArray()); + Assert.Equal("domain/user", body); + } + + [Fact] + public async Task FromRouteWithUrlDecodeDefaultPreservesEncodedValues() + { + var httpContext = CreateHttpContext(); + var responseBodyStream = new MemoryStream(); + httpContext.Response.Body = responseBodyStream; + + httpContext.Request.RouteValues["userId"] = "domain%2Fuser"; + + var factoryResult = RequestDelegateFactory.Create( + ([FromRoute] string userId) => userId, + new() { RouteParameterNames = new[] { "userId" } }); + + await factoryResult.RequestDelegate(httpContext); + + Assert.Equal(200, httpContext.Response.StatusCode); + var body = Encoding.UTF8.GetString(responseBodyStream.ToArray()); + // Default behavior: %2F is NOT decoded + Assert.Equal("domain%2Fuser", body); + } + + [Fact] + public async Task FromRouteWithUrlDecodeHandlesNullValues() + { + var httpContext = CreateHttpContext(); + httpContext.Response.Body = new MemoryStream(); + + // Route value not set — null case + var factoryResult = RequestDelegateFactory.Create( + ([FromRoute(UrlDecode = true)] string? userId) => userId ?? string.Empty, + new() { RouteParameterNames = new[] { "userId" } }); + + await factoryResult.RequestDelegate(httpContext); + + Assert.Equal(200, httpContext.Response.StatusCode); + } + + [Fact] + public async Task FromRouteWithUrlDecodeDecodesMultipleEncodedCharacters() + { + var httpContext = CreateHttpContext(); + var responseBodyStream = new MemoryStream(); + httpContext.Response.Body = responseBodyStream; + + // Multiple encoded characters: %2F (/) and %2B (+) and %20 (space) + httpContext.Request.RouteValues["value"] = "a%2Fb%2Bc%20d"; + + var factoryResult = RequestDelegateFactory.Create( + ([FromRoute(UrlDecode = true)] string value) => value, + new() { RouteParameterNames = new[] { "value" } }); + + await factoryResult.RequestDelegate(httpContext); + + Assert.Equal(200, httpContext.Response.StatusCode); + var body = Encoding.UTF8.GetString(responseBodyStream.ToArray()); + Assert.Equal("a/b+c d", body); + } + public static object?[][] TryParsableArrayParameters { get @@ -3633,6 +3712,7 @@ private record struct TodoStruct(int Id, string? Name, bool IsComplete) : ITodo; private class FromRouteAttribute : Attribute, IFromRouteMetadata { public string? Name { get; set; } + public bool UrlDecode { get; set; } } private class FromQueryAttribute : Attribute, IFromQueryMetadata diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.RouteParameter.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.RouteParameter.cs index 4add5af92414..e2e4f33af79c 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.RouteParameter.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.RouteParameter.cs @@ -231,4 +231,49 @@ public async Task RequestDelegatePopulatesFromRouteParameterBasedOnParameterName Assert.Equal(originalRouteParam, httpContext.Items["input"]); } + + [Fact] + public async Task FromRouteWithUrlDecodeTrueDecodesPercentEncodedValues() + { + var source = """app.MapGet("/{userId}", ([FromRoute(UrlDecode = true)] string userId) => userId);"""; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoint = GetEndpointFromCompilation(compilation); + + var httpContext = CreateHttpContext(); + httpContext.Request.RouteValues["userId"] = "domain%2Fuser"; + + await endpoint.RequestDelegate(httpContext); + + await VerifyResponseBodyAsync(httpContext, "domain/user"); + } + + [Fact] + public async Task FromRouteWithUrlDecodeDefaultPreservesEncodedValues() + { + var source = """app.MapGet("/{userId}", ([FromRoute] string userId) => userId);"""; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoint = GetEndpointFromCompilation(compilation); + + var httpContext = CreateHttpContext(); + httpContext.Request.RouteValues["userId"] = "domain%2Fuser"; + + await endpoint.RequestDelegate(httpContext); + + await VerifyResponseBodyAsync(httpContext, "domain%2Fuser"); + } + + [Fact] + public async Task FromRouteWithUrlDecodeDecodesMultipleEncodedCharacters() + { + var source = """app.MapGet("/{value}", ([FromRoute(UrlDecode = true)] string value) => value);"""; + var (_, compilation) = await RunGeneratorAsync(source); + var endpoint = GetEndpointFromCompilation(compilation); + + var httpContext = CreateHttpContext(); + httpContext.Request.RouteValues["value"] = "a%2Fb%2Bc%20d"; + + await endpoint.RequestDelegate(httpContext); + + await VerifyResponseBodyAsync(httpContext, "a/b+c d"); + } } diff --git a/src/Mvc/Mvc.Core/src/FromRouteAttribute.cs b/src/Mvc/Mvc.Core/src/FromRouteAttribute.cs index 6524c70af5ab..1a0c6f6124e6 100644 --- a/src/Mvc/Mvc.Core/src/FromRouteAttribute.cs +++ b/src/Mvc/Mvc.Core/src/FromRouteAttribute.cs @@ -34,4 +34,19 @@ public class FromRouteAttribute : Attribute, IBindingSourceMetadata, IModelNameP /// The name. /// public string? Name { get; set; } + + /// + /// Gets or sets a value indicating whether the route parameter value should be fully URL-decoded + /// using . + /// + /// + /// When set to , characters such as %2F (forward slash) that + /// are normally preserved in route values will be decoded. Defaults to . + /// + /// + /// + /// app.MapGet("/users/{userId}", ([FromRoute(UrlDecode = true)] string userId) => userId); + /// + /// + public bool UrlDecode { get; set; } } diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/SimpleTypeModelBinder.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/SimpleTypeModelBinder.cs index 6f96c36dcf4e..275c951acca1 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/SimpleTypeModelBinder.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/SimpleTypeModelBinder.cs @@ -5,6 +5,7 @@ using System.ComponentModel; using System.Runtime.ExceptionServices; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders; @@ -56,6 +57,12 @@ public Task BindModelAsync(ModelBindingContext bindingContext) ? valueProviderResult.Values.ToString() : valueProviderResult.FirstValue; + // Apply URL decoding when FromRoute(UrlDecode = true) is specified + if (value is not null && ShouldUrlDecodeRouteValue(bindingContext)) + { + value = Uri.UnescapeDataString(value); + } + object? model; if (bindingContext.ModelType == typeof(string)) { @@ -104,6 +111,37 @@ public Task BindModelAsync(ModelBindingContext bindingContext) return Task.CompletedTask; } + // Checks if the current binding context targets a route parameter with UrlDecode = true + private static bool ShouldUrlDecodeRouteValue(ModelBindingContext bindingContext) + { + if (bindingContext.BindingSource != BindingSource.Path) + { + return false; + } + + if (bindingContext.ModelMetadata is not Metadata.DefaultModelMetadata defaultMetadata) + { + return false; + } + + var attributes = defaultMetadata.Attributes.ParameterAttributes + ?? defaultMetadata.Attributes.PropertyAttributes; + if (attributes is null) + { + return false; + } + + foreach (var attribute in attributes) + { + if (attribute is IFromRouteMetadata { UrlDecode: true }) + { + return true; + } + } + + return false; + } + /// /// If the is , verifies that it is allowed to be , /// otherwise notifies the about the invalid . diff --git a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..32b56df28897 100644 --- a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.AspNetCore.Mvc.FromRouteAttribute.UrlDecode.get -> bool +Microsoft.AspNetCore.Mvc.FromRouteAttribute.UrlDecode.set -> void diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs index 291facb06f8d..73adc86ad040 100644 --- a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs +++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs @@ -3,6 +3,7 @@ using System.Globalization; using Microsoft.AspNetCore.InternalTesting; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; @@ -494,6 +495,87 @@ private static DefaultModelBindingContext GetBindingContext(Type modelType) }; } + private static DefaultModelBindingContext GetBindingContextForParameter(Type controllerType, string methodName, string paramName) + { + var provider = new Metadata.DefaultModelMetadataProvider( + new DefaultCompositeMetadataDetailsProvider(new IMetadataDetailsProvider[] + { + new DefaultBindingMetadataProvider() + })); + var method = controllerType.GetMethod(methodName)!; + var parameter = method.GetParameters().First(p => p.Name == paramName); + var metadata = provider.GetMetadataForParameter(parameter); + + return new DefaultModelBindingContext + { + ModelMetadata = metadata, + ModelName = paramName, + ModelState = new ModelStateDictionary(), + BindingSource = metadata.BindingSource, + ValueProvider = new SimpleValueProvider() + }; + } + + [Fact] + public async Task BindModelAsync_UrlDecodesRouteValue_WhenFromRouteUrlDecodeIsTrue() + { + var bindingContext = GetBindingContextForParameter( + typeof(UrlDecodeTestController), nameof(UrlDecodeTestController.WithUrlDecode), "userId"); + bindingContext.ValueProvider = new SimpleValueProvider + { + { "userId", "domain%2Fuser" } + }; + + var binder = new SimpleTypeModelBinder(typeof(string), NullLoggerFactory.Instance); + + await binder.BindModelAsync(bindingContext); + + Assert.True(bindingContext.Result.IsModelSet); + Assert.Equal("domain/user", bindingContext.Result.Model); + } + + [Fact] + public async Task BindModelAsync_PreservesEncodedRouteValue_WhenFromRouteUrlDecodeIsFalse() + { + var bindingContext = GetBindingContextForParameter( + typeof(UrlDecodeTestController), nameof(UrlDecodeTestController.WithoutUrlDecode), "userId"); + bindingContext.ValueProvider = new SimpleValueProvider + { + { "userId", "domain%2Fuser" } + }; + + var binder = new SimpleTypeModelBinder(typeof(string), NullLoggerFactory.Instance); + + await binder.BindModelAsync(bindingContext); + + Assert.True(bindingContext.Result.IsModelSet); + Assert.Equal("domain%2Fuser", bindingContext.Result.Model); + } + + [Fact] + public async Task BindModelAsync_UrlDecodesMultipleEncodedCharacters() + { + var bindingContext = GetBindingContextForParameter( + typeof(UrlDecodeTestController), nameof(UrlDecodeTestController.WithUrlDecode), "userId"); + bindingContext.ValueProvider = new SimpleValueProvider + { + { "userId", "a%2Fb%2Bc%20d" } + }; + + var binder = new SimpleTypeModelBinder(typeof(string), NullLoggerFactory.Instance); + + await binder.BindModelAsync(bindingContext); + + Assert.True(bindingContext.Result.IsModelSet); + Assert.Equal("a/b+c d", bindingContext.Result.Model); + } + + private class UrlDecodeTestController + { + public void WithUrlDecode([FromRoute(UrlDecode = true)] string userId) { } + public void WithoutUrlDecode([FromRoute] string userId) { } + } + private sealed class TestClass { } From 2bd11b3969b01b3cbb23845ca20c079fecad0f15 Mon Sep 17 00:00:00 2001 From: Mike Kistler Date: Mon, 2 Mar 2026 14:48:33 -0600 Subject: [PATCH 2/2] Decode route values from raw target to prevent double-decoding Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Emitters/EndpointParameterEmitter.cs | 13 +- .../src/PublicAPI.Unshipped.txt | 2 + .../src/RequestDelegateFactory.cs | 33 +-- .../src/RouteValueUrlDecoder.cs | 214 ++++++++++++++++++ .../test/RequestDelegateFactoryTests.cs | 57 ++++- ...estDelegateCreationTests.RouteParameter.cs | 5 + .../Binders/SimpleTypeModelBinder.cs | 6 +- .../Binders/SimpleTypeModelBinderTest.cs | 30 ++- 8 files changed, 320 insertions(+), 40 deletions(-) create mode 100644 src/Http/Http.Extensions/src/RouteValueUrlDecoder.cs diff --git a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/Emitters/EndpointParameterEmitter.cs b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/Emitters/EndpointParameterEmitter.cs index f81226faf9ba..38387b06eae3 100644 --- a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/Emitters/EndpointParameterEmitter.cs +++ b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/Emitters/EndpointParameterEmitter.cs @@ -201,18 +201,11 @@ internal static void EmitRouteParameterPreparation(this EndpointParameter endpoi codeWriter.WriteLine($$"""throw new InvalidOperationException($"'{{endpointParameter.LookupName}}' is not a route parameter.");"""); codeWriter.EndBlock(); - var assigningCode = $"(string?)httpContext.Request.RouteValues[\"{endpointParameter.LookupName}\"]"; + var assigningCode = endpointParameter.UrlDecode + ? $"RouteValueUrlDecoder.GetUrlDecodedRouteValue(httpContext, \"{endpointParameter.LookupName}\")" + : $"(string?)httpContext.Request.RouteValues[\"{endpointParameter.LookupName}\"]"; codeWriter.WriteLine($"var {endpointParameter.EmitAssigningCodeResult()} = {assigningCode};"); - // Apply Uri.UnescapeDataString to fully decode percent-encoded route values (e.g. %2F → /) - if (endpointParameter.UrlDecode) - { - codeWriter.WriteLine($"if ({endpointParameter.EmitAssigningCodeResult()} is not null)"); - codeWriter.StartBlock(); - codeWriter.WriteLine($"{endpointParameter.EmitAssigningCodeResult()} = Uri.UnescapeDataString({endpointParameter.EmitAssigningCodeResult()});"); - codeWriter.EndBlock(); - } - if (!endpointParameter.IsOptional) { codeWriter.WriteLine($"if ({endpointParameter.EmitAssigningCodeResult()} == null)"); diff --git a/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..eda86cf20e97 100644 --- a/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.AspNetCore.Http.RouteValueUrlDecoder +static Microsoft.AspNetCore.Http.RouteValueUrlDecoder.GetUrlDecodedRouteValue(Microsoft.AspNetCore.Http.HttpContext! httpContext, string! parameterName) -> string? diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs index ac4b8de3034a..54795a362c9f 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs @@ -60,7 +60,7 @@ public static partial class RequestDelegateFactory private static readonly MethodInfo ResultWriteResponseAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteResultWriteResponse), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo StringResultWriteResponseAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteWriteStringResponseAsync), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo StringIsNullOrEmptyMethod = typeof(string).GetMethod(nameof(string.IsNullOrEmpty), BindingFlags.Static | BindingFlags.Public)!; - private static readonly MethodInfo UriUnescapeDataStringMethod = typeof(Uri).GetMethod(nameof(Uri.UnescapeDataString), BindingFlags.Static | BindingFlags.Public, new[] { typeof(string) })!; + private static readonly MethodInfo GetUrlDecodedRouteValueMethod = typeof(RouteValueUrlDecoder).GetMethod(nameof(RouteValueUrlDecoder.GetUrlDecodedRouteValue), BindingFlags.Static | BindingFlags.Public)!; private static readonly MethodInfo WrapObjectAsValueTaskMethod = typeof(RequestDelegateFactory).GetMethod(nameof(WrapObjectAsValueTask), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo TaskOfTToValueTaskOfObjectMethod = typeof(RequestDelegateFactory).GetMethod(nameof(TaskOfTToValueTaskOfObject), BindingFlags.NonPublic | BindingFlags.Static)!; private static readonly MethodInfo ValueTaskOfTToValueTaskOfObjectMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ValueTaskOfTToValueTaskOfObject), BindingFlags.NonPublic | BindingFlags.Static)!; @@ -735,7 +735,14 @@ private static Expression CreateArgument(ParameterInfo parameter, RequestDelegat throw new InvalidOperationException($"'{routeName}' is not a route parameter."); } - return BindParameterFromProperty(parameter, RouteValuesExpr, RouteValuesIndexerProperty, routeName, factoryContext, "route", urlDecode: routeAttribute.UrlDecode); + if (routeAttribute.UrlDecode) + { + // Decode from the raw request target to avoid double-decoding. + var valueExpression = Expression.Call(GetUrlDecodedRouteValueMethod, HttpContextExpr, Expression.Constant(routeName)); + return BindParameterFromValue(parameter, valueExpression, factoryContext, "route"); + } + + return BindParameterFromProperty(parameter, RouteValuesExpr, RouteValuesIndexerProperty, routeName, factoryContext, "route"); } else if (parameterCustomAttributes.OfType().FirstOrDefault() is { } queryAttribute) { @@ -1569,20 +1576,6 @@ private static Expression GetValueFromProperty(MemberExpression sourceExpression return Expression.Convert(indexExpression, returnType ?? typeof(string)); } - // Wraps a string expression with null-safe Uri.UnescapeDataString: value is null ? null : Uri.UnescapeDataString(value) - private static Expression ApplyUrlDecode(Expression valueExpression) - { - var tempVar = Expression.Variable(typeof(string), "routeValue"); - return Expression.Block( - typeof(string), - new[] { tempVar }, - Expression.Assign(tempVar, valueExpression), - Expression.Condition( - Expression.Equal(tempVar, Expression.Constant(null, typeof(string))), - Expression.Constant(null, typeof(string)), - Expression.Call(UriUnescapeDataStringMethod, tempVar))); - } - private static Expression BindParameterFromProperties(ParameterInfo parameter, RequestDelegateFactoryContext factoryContext) { var parameterType = parameter.ParameterType; @@ -1982,18 +1975,12 @@ private static Expression BindParameterFromExpression( Expression.Convert(CreateDefaultValueExpression(parameter.DefaultValue, parameter.ParameterType), parameter.ParameterType))); } - private static Expression BindParameterFromProperty(ParameterInfo parameter, MemberExpression property, PropertyInfo itemProperty, string key, RequestDelegateFactoryContext factoryContext, string source, bool urlDecode = false) + private static Expression BindParameterFromProperty(ParameterInfo parameter, MemberExpression property, PropertyInfo itemProperty, string key, RequestDelegateFactoryContext factoryContext, string source) { var valueExpression = (source == "header" && parameter.ParameterType.IsArray) ? Expression.Call(GetHeaderSplitMethod, property, Expression.Constant(key)) : GetValueFromProperty(property, itemProperty, key, GetExpressionType(parameter.ParameterType)); - // Apply Uri.UnescapeDataString to fully decode percent-encoded route values (e.g. %2F → /) - if (urlDecode) - { - valueExpression = ApplyUrlDecode(valueExpression); - } - return BindParameterFromValue(parameter, valueExpression, factoryContext, source); } diff --git a/src/Http/Http.Extensions/src/RouteValueUrlDecoder.cs b/src/Http/Http.Extensions/src/RouteValueUrlDecoder.cs new file mode 100644 index 000000000000..a321a22e5529 --- /dev/null +++ b/src/Http/Http.Extensions/src/RouteValueUrlDecoder.cs @@ -0,0 +1,214 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Http.Metadata; + +namespace Microsoft.AspNetCore.Http; + +/// +/// Provides methods for extracting and decoding route parameter values from the raw request target +/// to avoid double-decoding that would occur when applying +/// to already partially-decoded route values. +/// +/// +/// This type is intended for use by generated code and should not be used directly. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public static class RouteValueUrlDecoder +{ + /// + /// Gets a fully URL-decoded route parameter value by extracting it from the raw request target + /// and applying a single pass of . + /// + /// The for the current request. + /// The name of the route parameter to decode. + /// The fully decoded route parameter value, or null if the parameter is not found. + public static string? GetUrlDecodedRouteValue(HttpContext httpContext, string parameterName) + { + var routeValue = httpContext.Request.RouteValues[parameterName]?.ToString(); + if (routeValue is null) + { + return null; + } + + // Try to get the raw (undecoded) request target. + var rawTarget = httpContext.Features.Get()?.RawTarget; + if (rawTarget is null) + { + // Raw target not available (e.g. non-Kestrel server). + // Return the route value without additional decoding to avoid double-decode risk. + return routeValue; + } + + // Get the route template from endpoint diagnostics metadata. + var endpoint = httpContext.GetEndpoint(); + var routeTemplate = endpoint?.Metadata.GetMetadata()?.Route; + if (routeTemplate is null) + { + return routeValue; + } + + // Find the parameter's segment index in the route template. + var (segmentIndex, isSimple, isCatchAll) = FindParameterSegment(routeTemplate, parameterName); + if (segmentIndex < 0 || !isSimple) + { + // Complex segment (e.g. {action}.{format}) or parameter not found. + // Cannot safely extract from the raw target. + return routeValue; + } + + // Strip the query string from the raw target. + var rawPath = rawTarget.AsSpan(); + var queryIndex = rawPath.IndexOf('?'); + if (queryIndex >= 0) + { + rawPath = rawPath[..queryIndex]; + } + + // Strip PathBase from the raw path. + var pathBase = httpContext.Request.PathBase.Value; + if (!string.IsNullOrEmpty(pathBase) && rawPath.StartsWith(pathBase.AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + rawPath = rawPath[pathBase.Length..]; + } + + // Extract the raw segment at the computed index and decode it. + var rawSegment = ExtractSegment(rawPath, segmentIndex, isCatchAll); + if (rawSegment.IsEmpty) + { + return routeValue; + } + + return Uri.UnescapeDataString(rawSegment.ToString()); + } + + internal static (int segmentIndex, bool isSimple, bool isCatchAll) FindParameterSegment(string template, string parameterName) + { + var segmentIndex = 0; + var i = 0; + + // Skip leading '/'. + if (i < template.Length && template[i] == '/') + { + i++; + } + + while (i < template.Length) + { + var segStart = i; + + // Find the end of the current segment. + while (i < template.Length && template[i] != '/') + { + i++; + } + + var segment = template.AsSpan(segStart, i - segStart); + if (TryMatchParameter(segment, parameterName, out var isSimple, out var isCatchAll)) + { + return (segmentIndex, isSimple, isCatchAll); + } + + segmentIndex++; + + // Skip '/'. + if (i < template.Length) + { + i++; + } + } + + return (-1, false, false); + } + + private static bool TryMatchParameter(ReadOnlySpan segment, string parameterName, out bool isSimple, out bool isCatchAll) + { + isSimple = false; + isCatchAll = false; + + var braceStart = segment.IndexOf('{'); + if (braceStart < 0) + { + return false; + } + + var braceEnd = segment.LastIndexOf('}'); + if (braceEnd < 0) + { + return false; + } + + // Extract the content between braces. + var content = segment[(braceStart + 1)..braceEnd]; + + // Handle catch-all prefix (** or *). + if (content.StartsWith("**")) + { + content = content[2..]; + isCatchAll = true; + } + else if (content.StartsWith("*")) + { + content = content[1..]; + isCatchAll = true; + } + + // Strip constraints (everything after ':'). + var colonIndex = content.IndexOf(':'); + if (colonIndex >= 0) + { + content = content[..colonIndex]; + } + + // Strip optional marker '?'. + if (content.EndsWith("?")) + { + content = content[..^1]; + } + + // Compare parameter name (case-insensitive). + if (!content.Equals(parameterName.AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // A segment is "simple" if it consists entirely of this one parameter. + isSimple = braceStart == 0 && braceEnd == segment.Length - 1; + + return true; + } + + private static ReadOnlySpan ExtractSegment(ReadOnlySpan rawPath, int targetIndex, bool isCatchAll) + { + var currentIndex = 0; + var i = 0; + + // Skip leading '/'. + if (i < rawPath.Length && rawPath[i] == '/') + { + i++; + } + + var segStart = i; + + while (i <= rawPath.Length) + { + if (i == rawPath.Length || rawPath[i] == '/') + { + if (currentIndex == targetIndex) + { + return isCatchAll ? rawPath[segStart..] : rawPath[segStart..i]; + } + + currentIndex++; + segStart = i + 1; + } + + i++; + } + + return ReadOnlySpan.Empty; + } +} diff --git a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs index 0545d50a4d68..1773c77e18ca 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs @@ -300,8 +300,9 @@ public async Task FromRouteWithUrlDecodeTrueDecodesPercentEncodedValues() var responseBodyStream = new MemoryStream(); httpContext.Response.Body = responseBodyStream; - // %2F is preserved by the server's path decoder, so route values contain literal "%2F" + // Simulate what Kestrel does: %2F is preserved in route values, raw target has the original encoding httpContext.Request.RouteValues["userId"] = "domain%2Fuser"; + SetupRawTargetAndEndpoint(httpContext, "/users/domain%2Fuser", "/users/{userId}"); var factoryResult = RequestDelegateFactory.Create( ([FromRoute(UrlDecode = true)] string userId) => userId, @@ -360,6 +361,7 @@ public async Task FromRouteWithUrlDecodeDecodesMultipleEncodedCharacters() // Multiple encoded characters: %2F (/) and %2B (+) and %20 (space) httpContext.Request.RouteValues["value"] = "a%2Fb%2Bc%20d"; + SetupRawTargetAndEndpoint(httpContext, "/items/a%2Fb%2Bc%20d", "/items/{value}"); var factoryResult = RequestDelegateFactory.Create( ([FromRoute(UrlDecode = true)] string value) => value, @@ -372,6 +374,52 @@ public async Task FromRouteWithUrlDecodeDecodesMultipleEncodedCharacters() Assert.Equal("a/b+c d", body); } + [Fact] + public async Task FromRouteWithUrlDecodeDoesNotDoubleDecodePercentEncoding() + { + var httpContext = CreateHttpContext(); + var responseBodyStream = new MemoryStream(); + httpContext.Response.Body = responseBodyStream; + + // Simulate double-encoding: original URL had %252F, Kestrel decoded %25 to %, leaving %2F in route value + httpContext.Request.RouteValues["userId"] = "domain%2Fuser"; + // Raw target still has %252F (the original encoding) + SetupRawTargetAndEndpoint(httpContext, "/users/domain%252Fuser", "/users/{userId}"); + + var factoryResult = RequestDelegateFactory.Create( + ([FromRoute(UrlDecode = true)] string userId) => userId, + new() { RouteParameterNames = new[] { "userId" } }); + + await factoryResult.RequestDelegate(httpContext); + + Assert.Equal(200, httpContext.Response.StatusCode); + var body = Encoding.UTF8.GetString(responseBodyStream.ToArray()); + // Single decode of %252F should yield %2F, NOT / + Assert.Equal("domain%2Fuser", body); + } + + [Fact] + public async Task FromRouteWithUrlDecodeFallsBackWhenRawTargetUnavailable() + { + var httpContext = CreateHttpContext(); + var responseBodyStream = new MemoryStream(); + httpContext.Response.Body = responseBodyStream; + + // No raw target or endpoint set — should fall back to the route value without decoding + httpContext.Request.RouteValues["userId"] = "domain%2Fuser"; + + var factoryResult = RequestDelegateFactory.Create( + ([FromRoute(UrlDecode = true)] string userId) => userId, + new() { RouteParameterNames = new[] { "userId" } }); + + await factoryResult.RequestDelegate(httpContext); + + Assert.Equal(200, httpContext.Response.StatusCode); + var body = Encoding.UTF8.GetString(responseBodyStream.ToArray()); + // Without raw target, falls back to the route value as-is + Assert.Equal("domain%2Fuser", body); + } + public static object?[][] TryParsableArrayParameters { get @@ -3445,6 +3493,13 @@ private EndpointBuilder CreateEndpointBuilderFromFilterFactories(IEnumerable(new HttpRequestFeature { RawTarget = rawTarget }); + var endpointBuilder = new RouteEndpointBuilder(_ => Task.CompletedTask, RoutePatternFactory.Parse(routeTemplate), 0); + httpContext.SetEndpoint(endpointBuilder.Build()); + } + private record MetadataService; private class AccessesServicesMetadataResult : IResult, IEndpointMetadataProvider diff --git a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.RouteParameter.cs b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.RouteParameter.cs index e2e4f33af79c..4be7d9603d56 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.RouteParameter.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.RouteParameter.cs @@ -1,5 +1,6 @@ // 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.Features; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Primitives; using System.Globalization; @@ -241,6 +242,8 @@ public async Task FromRouteWithUrlDecodeTrueDecodesPercentEncodedValues() var httpContext = CreateHttpContext(); httpContext.Request.RouteValues["userId"] = "domain%2Fuser"; + httpContext.Features.Set(new HttpRequestFeature { RawTarget = "/domain%2Fuser" }); + httpContext.SetEndpoint(endpoint); await endpoint.RequestDelegate(httpContext); @@ -271,6 +274,8 @@ public async Task FromRouteWithUrlDecodeDecodesMultipleEncodedCharacters() var httpContext = CreateHttpContext(); httpContext.Request.RouteValues["value"] = "a%2Fb%2Bc%20d"; + httpContext.Features.Set(new HttpRequestFeature { RawTarget = "/a%2Fb%2Bc%20d" }); + httpContext.SetEndpoint(endpoint); await endpoint.RequestDelegate(httpContext); diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/SimpleTypeModelBinder.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/SimpleTypeModelBinder.cs index 275c951acca1..dc6446041be9 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/SimpleTypeModelBinder.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/SimpleTypeModelBinder.cs @@ -5,6 +5,7 @@ using System.ComponentModel; using System.Runtime.ExceptionServices; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.Logging; @@ -57,10 +58,11 @@ public Task BindModelAsync(ModelBindingContext bindingContext) ? valueProviderResult.Values.ToString() : valueProviderResult.FirstValue; - // Apply URL decoding when FromRoute(UrlDecode = true) is specified + // Apply URL decoding when FromRoute(UrlDecode = true) is specified. + // Decode from the raw request target to avoid double-decoding. if (value is not null && ShouldUrlDecodeRouteValue(bindingContext)) { - value = Uri.UnescapeDataString(value); + value = RouteValueUrlDecoder.GetUrlDecodedRouteValue(bindingContext.HttpContext, bindingContext.ModelName) ?? value; } object? model; diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs index 73adc86ad040..52d2f86293f0 100644 --- a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs +++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs @@ -2,8 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.InternalTesting; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; @@ -495,7 +499,7 @@ private static DefaultModelBindingContext GetBindingContext(Type modelType) }; } - private static DefaultModelBindingContext GetBindingContextForParameter(Type controllerType, string methodName, string paramName) + private static DefaultModelBindingContext GetBindingContextForParameter(Type controllerType, string methodName, string paramName, string rawTarget = null, string routeTemplate = null, string routeValue = null) { var provider = new Metadata.DefaultModelMetadataProvider( new DefaultCompositeMetadataDetailsProvider(new IMetadataDetailsProvider[] @@ -506,13 +510,29 @@ private static DefaultModelBindingContext GetBindingContextForParameter(Type con var parameter = method.GetParameters().First(p => p.Name == paramName); var metadata = provider.GetMetadataForParameter(parameter); + var httpContext = new DefaultHttpContext(); + if (rawTarget is not null) + { + httpContext.Features.Set(new HttpRequestFeature { RawTarget = rawTarget }); + } + if (routeTemplate is not null) + { + var endpointBuilder = new RouteEndpointBuilder(_ => Task.CompletedTask, RoutePatternFactory.Parse(routeTemplate), 0); + httpContext.SetEndpoint(endpointBuilder.Build()); + } + if (routeValue is not null) + { + httpContext.Request.RouteValues[paramName] = routeValue; + } + return new DefaultModelBindingContext { ModelMetadata = metadata, ModelName = paramName, ModelState = new ModelStateDictionary(), BindingSource = metadata.BindingSource, - ValueProvider = new SimpleValueProvider() + ValueProvider = new SimpleValueProvider(), + ActionContext = new ActionContext { HttpContext = httpContext }, }; } @@ -520,7 +540,8 @@ private static DefaultModelBindingContext GetBindingContextForParameter(Type con public async Task BindModelAsync_UrlDecodesRouteValue_WhenFromRouteUrlDecodeIsTrue() { var bindingContext = GetBindingContextForParameter( - typeof(UrlDecodeTestController), nameof(UrlDecodeTestController.WithUrlDecode), "userId"); + typeof(UrlDecodeTestController), nameof(UrlDecodeTestController.WithUrlDecode), "userId", + rawTarget: "/users/domain%2Fuser", routeTemplate: "/users/{userId}", routeValue: "domain%2Fuser"); bindingContext.ValueProvider = new SimpleValueProvider { { "userId", "domain%2Fuser" } @@ -556,7 +577,8 @@ public async Task BindModelAsync_PreservesEncodedRouteValue_WhenFromRouteUrlDeco public async Task BindModelAsync_UrlDecodesMultipleEncodedCharacters() { var bindingContext = GetBindingContextForParameter( - typeof(UrlDecodeTestController), nameof(UrlDecodeTestController.WithUrlDecode), "userId"); + typeof(UrlDecodeTestController), nameof(UrlDecodeTestController.WithUrlDecode), "userId", + rawTarget: "/users/a%2Fb%2Bc%20d", routeTemplate: "/users/{userId}", routeValue: "a%2Fb%2Bc%20d"); bindingContext.ValueProvider = new SimpleValueProvider { { "userId", "a%2Fb%2Bc%20d" }