Add UrlDecode option to FromRouteAttribute for full percent-decoding#65434
Add UrlDecode option to FromRouteAttribute for full percent-decoding#65434mikekistler wants to merge 1 commit intodotnet:mainfrom
Conversation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR adds an opt-in UrlDecode switch to [FromRoute] / IFromRouteMetadata to enable full percent-decoding of route values (e.g., decoding %2F to /) across MVC model binding, Minimal APIs runtime binding, and the Request Delegate Generator path—addressing current inconsistent decoding behavior.
Changes:
- Adds
UrlDecodetoIFromRouteMetadata(defaulting tofalse) and toFromRouteAttribute. - Implements full percent-decoding in Minimal APIs (runtime
RequestDelegateFactory+ RDG emitter) and MVC (SimpleTypeModelBinder) whenUrlDecode = true. - Adds new test coverage for decode-on/decode-off and multi-encoding scenarios.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Http/Http.Abstractions/src/Metadata/IFromRouteMetadata.cs | Adds UrlDecode (default interface implementation) to the route metadata contract. |
| src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt | Public API annotation for IFromRouteMetadata.UrlDecode. |
| src/Mvc/Mvc.Core/src/FromRouteAttribute.cs | Adds UrlDecode to [FromRoute] for MVC + Minimal API attribute usage. |
| src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt | Public API annotation for FromRouteAttribute.UrlDecode. |
| src/Http/Http.Extensions/src/RequestDelegateFactory.cs | Applies URL decoding during Minimal API runtime route binding when enabled. |
| src/Http/Http.Extensions/gen/.../EndpointParameter.cs | Carries UrlDecode through RDG’s static model and equality logic. |
| src/Http/Http.Extensions/gen/.../EndpointParameterEmitter.cs | Emits Uri.UnescapeDataString for route params when UrlDecode is enabled. |
| src/Mvc/Mvc.Core/src/ModelBinding/Binders/SimpleTypeModelBinder.cs | Applies URL decoding during MVC simple-type route binding when enabled. |
| src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs | Adds Minimal API runtime tests for UrlDecode on/off and multi-character decoding. |
| src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.RouteParameter.cs | Adds RDG tests for UrlDecode route decoding behavior. |
| src/Mvc/Mvc.Core/test/ModelBinding/Binders/SimpleTypeModelBinderTest.cs | Adds MVC binder tests verifying decode-on/decode-off behavior. |
Comments suppressed due to low confidence (1)
src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.RequestDelegateGenerator/StaticRouteHandlerModel/EndpointParameter.cs:611
UrlDecodeaffects the emitted binding code, but it isn’t included inSignatureEquals. Since endpoint delegate de-duplication usesSignatureEquals, two otherwise-identical endpoints that differ only byUrlDecodecould be treated as the same signature and reuse the wrong generated delegate. IncludeUrlDecode(and update the related signature hash if needed) in the signature comparison.
public bool SignatureEquals(object obj) =>
obj is EndpointParameter other &&
SymbolEqualityComparer.IncludeNullability.Equals(other.Type, Type) &&
// The name of the parameter matters when we are querying for a specific parameter using
// an indexer, like `context.Request.RouteValues["id"]` or `context.Request.Query["id"]`
| 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))); |
There was a problem hiding this comment.
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).
| 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)); |
| : GetValueFromProperty(property, itemProperty, key, GetExpressionType(parameter.ParameterType)); | ||
|
|
||
| // Apply Uri.UnescapeDataString to fully decode percent-encoded route values (e.g. %2F → /) | ||
| if (urlDecode) |
There was a problem hiding this comment.
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)) |
| codeWriter.StartBlock(); | ||
| codeWriter.WriteLine($"{endpointParameter.EmitAssigningCodeResult()} = Uri.UnescapeDataString({endpointParameter.EmitAssigningCodeResult()});"); | ||
| codeWriter.EndBlock(); |
There was a problem hiding this comment.
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.
| 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(); |
| Assert.Equal("domain/user", body); | ||
| } | ||
|
|
||
| [Fact] |
There was a problem hiding this comment.
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.
| [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] |
Adds a
UrlDecodeproperty toIFromRouteMetadataandFromRouteAttributethat enables full RFC 3986 percent-decoding of route parameter values usingUri.UnescapeDataString(). This addresses the inconsistent decoding behavior where some characters (like%2F) are preserved while others (like%2B) are decoded by the server's path decoder.The feature is opt-in (defaults to
false) to maintain backward compatibility. Users enable it with[FromRoute(UrlDecode = true)].Implementation covers:
IFromRouteMetadatainterface (default interface method)FromRouteAttribute(newUrlDecodeproperty)RequestDelegateFactory)EndpointParameterEmitter)SimpleTypeModelBinder)Tests added: 10 new tests across Minimal APIs (runtime + RDG) and MVC covering decode-on, decode-off, null handling, and multi-character encoding.
Fixes #11544