Skip to content
Closed
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 @@ -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)
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
2 changes: 2 additions & 0 deletions src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
#nullable enable
Microsoft.AspNetCore.Http.RouteValueUrlDecoder
static Microsoft.AspNetCore.Http.RouteValueUrlDecoder.GetUrlDecodedRouteValue(Microsoft.AspNetCore.Http.HttpContext! httpContext, string! parameterName) -> string?
8 changes: 8 additions & 0 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 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)!;
Expand Down Expand Up @@ -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<IFromQueryMetadata>().FirstOrDefault() is { } queryAttribute)
Expand Down
214 changes: 214 additions & 0 deletions src/Http/Http.Extensions/src/RouteValueUrlDecoder.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Provides methods for extracting and decoding route parameter values from the raw request target
/// to avoid double-decoding that would occur when applying <see cref="Uri.UnescapeDataString(string)"/>
/// to already partially-decoded route values.
/// </summary>
/// <remarks>
/// This type is intended for use by generated code and should not be used directly.
/// </remarks>
[EditorBrowsable(EditorBrowsableState.Never)]
public static class RouteValueUrlDecoder
{
/// <summary>
/// Gets a fully URL-decoded route parameter value by extracting it from the raw request target
/// and applying a single pass of <see cref="Uri.UnescapeDataString(string)"/>.
/// </summary>
/// <param name="httpContext">The <see cref="HttpContext"/> for the current request.</param>
/// <param name="parameterName">The name of the route parameter to decode.</param>
/// <returns>The fully decoded route parameter value, or <c>null</c> if the parameter is not found.</returns>
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<IHttpRequestFeature>()?.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<IRouteDiagnosticsMetadata>()?.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<char> 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<char> ExtractSegment(ReadOnlySpan<char> 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<char>.Empty;
}
}
Loading
Loading