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