-
Notifications
You must be signed in to change notification settings - Fork 10.6k
Add UrlDecode option to FromRouteAttribute for full percent-decoding #65434
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<IFromQueryMetadata>().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))); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+1576
to
+1583
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
AI
Feb 15, 2026
There was a problem hiding this comment.
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).
| if (urlDecode) | |
| if (urlDecode && valueExpression.Type == typeof(string)) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [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] |
| 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 |
There was a problem hiding this comment.
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.UnescapeDataStringwithout handling malformed percent-encoding. WithUrlDecodeenabled this can turn an otherwise-valid request into an unhandled exception/500. Consider emitting a try/catch (or usingUri.TryUnescapeDataString) that marks binding as failed and follows the existingwasParamCheckFailure+logOrThrowExceptionHelperpattern.