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 { }