Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/Http/Http.Abstractions/src/Metadata/IFromRouteMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,23 @@ public interface IFromRouteMetadata
/// The <see cref="HttpRequest.RouteValues"/> name.
/// </summary>
string? Name { get; }

/// <summary>
/// Gets a value indicating whether the route parameter value should be fully URL-decoded
/// using <see cref="System.Uri.UnescapeDataString(string)"/>.
/// </summary>
/// <remarks>
/// <para>
/// By default, some characters such as <c>/</c> (<c>%2F</c>) are not decoded
/// in route values because they are decoded at the server level with special handling.
/// Setting this property to <c>true</c> ensures the value is fully percent-decoded
/// per RFC 3986.
/// </para>
/// <example>
/// <code>
/// app.MapGet("/users/{userId}", ([FromRoute(UrlDecode = true)] string userId) => userId);
/// </code>
/// </example>
/// </remarks>
bool UrlDecode => false;
}
1 change: 1 addition & 0 deletions src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#nullable enable
Microsoft.AspNetCore.Http.Metadata.IFromRouteMetadata.UrlDecode.get -> bool
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment on lines +211 to +213
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated route handler code calls Uri.UnescapeDataString without handling malformed percent-encoding. With UrlDecode enabled this can turn an otherwise-valid request into an unhandled exception/500. Consider emitting a try/catch (or using Uri.TryUnescapeDataString) that marks binding as failed and follows the existing wasParamCheckFailure + logOrThrowExceptionHelper pattern.

Suggested change
codeWriter.StartBlock();
codeWriter.WriteLine($"{endpointParameter.EmitAssigningCodeResult()} = Uri.UnescapeDataString({endpointParameter.EmitAssigningCodeResult()});");
codeWriter.EndBlock();
codeWriter.StartBlock();
codeWriter.WriteLine("try");
codeWriter.StartBlock();
codeWriter.WriteLine($"{endpointParameter.EmitAssigningCodeResult()} = Uri.UnescapeDataString({endpointParameter.EmitAssigningCodeResult()});");
codeWriter.EndBlock();
codeWriter.WriteLine("catch (UriFormatException)");
codeWriter.StartBlock();
codeWriter.WriteLine("wasParamCheckFailure = true;");
codeWriter.EndBlock();
codeWriter.EndBlock();

Copilot uses AI. Check for mistakes.
}

if (!endpointParameter.IsOptional)
{
codeWriter.WriteLine($"if ({endpointParameter.EmitAssigningCodeResult()} == null)");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>("UrlDecode", out var urlDecode) && urlDecode;
}
else if (attributes.TryGetAttributeImplementingInterface(wellKnownTypes.Get(WellKnownType.Microsoft_AspNetCore_Http_Metadata_IFromQueryMetadata), out var fromQueryAttribute))
{
Expand Down Expand Up @@ -297,6 +298,7 @@ private static bool ImplementsIEndpointParameterMetadataProvider(ITypeSymbol typ
public bool IsParsable { get; set; }
public Func<string, string, string>? PreferredTryParseInvocation { get; set; }
public bool IsStringValues { get; set; }
public bool UrlDecode { get; set; }

public BindabilityMethod? BindMethod { get; set; }
public IMethodSymbol? BindableMethodSymbol { get; set; }
Expand Down Expand Up @@ -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 &&
Expand Down
25 changes: 23 additions & 2 deletions src/Http/Http.Extensions/src/RequestDelegateFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)!;
Expand Down Expand Up @@ -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<IFromQueryMetadata>().FirstOrDefault() is { } queryAttribute)
{
Expand Down Expand Up @@ -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)));
Comment on lines +1576 to +1583
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uri.UnescapeDataString can throw on malformed percent-encoding (e.g. trailing % or non-hex). In the minimal-API runtime path this would currently bubble out as a 500 instead of being treated as a binding failure (400 / ThrowOnBadRequest). Consider switching to Uri.TryUnescapeDataString (or wrapping in try/catch) and integrating failures into the existing parameter-binding failure flow (set wasParamCheckFailure, log/throw consistently).

Suggested change
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)));
var unescapedVar = Expression.Variable(typeof(string), "unescapedRouteValue");
var tryUnescape = Expression.TryCatch(
Expression.Block(
typeof(string),
Expression.Assign(unescapedVar, Expression.Call(UriUnescapeDataStringMethod, tempVar)),
unescapedVar),
Expression.Catch(
typeof(FormatException),
tempVar));
return Expression.Block(
typeof(string),
new[] { tempVar, unescapedVar },
Expression.Assign(tempVar, valueExpression),
Expression.Condition(
Expression.Equal(tempVar, Expression.Constant(null, typeof(string))),
Expression.Constant(null, typeof(string)),
tryUnescape));

Copilot uses AI. Check for mistakes.
}

private static Expression BindParameterFromProperties(ParameterInfo parameter, RequestDelegateFactoryContext factoryContext)
{
var parameterType = parameter.ParameterType;
Expand Down Expand Up @@ -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)
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ApplyUrlDecode assumes the value expression is a string, but BindParameterFromProperty can produce non-string expressions (e.g. string[], StringValues) via GetExpressionType. If someone uses [FromRoute(UrlDecode = true)] on those parameter types, expression construction will throw due to the type mismatch. Restrict URL-decoding to cases where valueExpression.Type == typeof(string) (and/or only for supported route-binding target types).

Suggested change
if (urlDecode)
if (urlDecode && valueExpression.Type == typeof(string))

Copilot uses AI. Check for mistakes.
{
valueExpression = ApplyUrlDecode(valueExpression);
}

return BindParameterFromValue(parameter, valueExpression, factoryContext, source);
}

Expand Down
80 changes: 80 additions & 0 deletions src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new UrlDecode behavior has no coverage for malformed percent-encoding (e.g. "abc%", "%2", "%ZZ"). Adding tests for these cases would help lock down the intended behavior (binding failure vs exception) for the runtime factory path when UrlDecode = true.

Suggested change
[Fact]
[Fact]
public async Task FromRouteWithUrlDecodeTrue_AllowsIncompleteEscapeSequenceAtEnd()
{
var httpContext = CreateHttpContext();
var responseBodyStream = new MemoryStream();
httpContext.Response.Body = responseBodyStream;
httpContext.Request.RouteValues["userId"] = "abc%";
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("abc%", body);
}
[Fact]
public async Task FromRouteWithUrlDecodeTrue_AllowsSingleHexDigitEscapeSequence()
{
var httpContext = CreateHttpContext();
var responseBodyStream = new MemoryStream();
httpContext.Response.Body = responseBodyStream;
httpContext.Request.RouteValues["userId"] = "%2";
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("%2", body);
}
[Fact]
public async Task FromRouteWithUrlDecodeTrue_AllowsInvalidHexDigitsInEscapeSequence()
{
var httpContext = CreateHttpContext();
var responseBodyStream = new MemoryStream();
httpContext.Response.Body = responseBodyStream;
httpContext.Request.RouteValues["userId"] = "%ZZ";
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("%ZZ", body);
}
[Fact]

Copilot uses AI. Check for mistakes.
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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
15 changes: 15 additions & 0 deletions src/Mvc/Mvc.Core/src/FromRouteAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,19 @@ public class FromRouteAttribute : Attribute, IBindingSourceMetadata, IModelNameP
/// The <see cref="HttpRequest.RouteValues"/> name.
/// </summary>
public string? Name { get; set; }

/// <summary>
/// Gets or sets a value indicating whether the route parameter value should be fully URL-decoded
/// using <see cref="Uri.UnescapeDataString(string)"/>.
/// </summary>
/// <remarks>
/// When set to <see langword="true"/>, characters such as <c>%2F</c> (forward slash) that
/// are normally preserved in route values will be decoded. Defaults to <see langword="false"/>.
/// </remarks>
/// <example>
/// <code>
/// app.MapGet("/users/{userId}", ([FromRoute(UrlDecode = true)] string userId) => userId);
/// </code>
/// </example>
public bool UrlDecode { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))
{
Expand Down Expand Up @@ -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;
}

/// <summary>
/// If the <paramref name="model" /> is <see langword="null" />, verifies that it is allowed to be <see langword="null" />,
/// otherwise notifies the <see cref="P:ModelBindingContext.ModelState" /> about the invalid <paramref name="valueProviderResult" />.
Expand Down
2 changes: 2 additions & 0 deletions src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
#nullable enable
Microsoft.AspNetCore.Mvc.FromRouteAttribute.UrlDecode.get -> bool
Microsoft.AspNetCore.Mvc.FromRouteAttribute.UrlDecode.set -> void
Loading
Loading