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..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,7 +201,9 @@ 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};"); if (!endpointParameter.IsOptional) 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/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 c51e7aae5a36..54795a362c9f 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 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)!; @@ -734,6 +735,13 @@ private static Expression CreateArgument(ParameterInfo parameter, RequestDelegat throw new InvalidOperationException($"'{routeName}' is not a route parameter."); } + 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) 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 61e7d2a46965..1773c77e18ca 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs @@ -293,6 +293,133 @@ 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; + + // 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, + 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"; + SetupRawTargetAndEndpoint(httpContext, "/items/a%2Fb%2Bc%20d", "/items/{value}"); + + 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); + } + + [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 @@ -3366,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 @@ -3633,6 +3767,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..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; @@ -231,4 +232,53 @@ 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"; + httpContext.Features.Set(new HttpRequestFeature { RawTarget = "/domain%2Fuser" }); + httpContext.SetEndpoint(endpoint); + + 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"; + httpContext.Features.Set(new HttpRequestFeature { RawTarget = "/a%2Fb%2Bc%20d" }); + httpContext.SetEndpoint(endpoint); + + 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..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,8 @@ using System.ComponentModel; using System.Runtime.ExceptionServices; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders; @@ -56,6 +58,13 @@ public Task BindModelAsync(ModelBindingContext bindingContext) ? valueProviderResult.Values.ToString() : valueProviderResult.FirstValue; + // 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 = RouteValueUrlDecoder.GetUrlDecodedRouteValue(bindingContext.HttpContext, bindingContext.ModelName) ?? value; + } + object? model; if (bindingContext.ModelType == typeof(string)) { @@ -104,6 +113,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..52d2f86293f0 100644 --- a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs +++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs @@ -2,7 +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; @@ -494,6 +499,105 @@ private static DefaultModelBindingContext GetBindingContext(Type modelType) }; } + 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[] + { + new DefaultBindingMetadataProvider() + })); + var method = controllerType.GetMethod(methodName)!; + 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(), + ActionContext = new ActionContext { HttpContext = httpContext }, + }; + } + + [Fact] + public async Task BindModelAsync_UrlDecodesRouteValue_WhenFromRouteUrlDecodeIsTrue() + { + var bindingContext = GetBindingContextForParameter( + typeof(UrlDecodeTestController), nameof(UrlDecodeTestController.WithUrlDecode), "userId", + rawTarget: "/users/domain%2Fuser", routeTemplate: "/users/{userId}", routeValue: "domain%2Fuser"); + 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", + rawTarget: "/users/a%2Fb%2Bc%20d", routeTemplate: "/users/{userId}", routeValue: "a%2Fb%2Bc%20d"); + 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 { }