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
51 changes: 45 additions & 6 deletions src/Http/Routing/src/Matching/DfaMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;

namespace Microsoft.AspNetCore.Routing.Matching;

Expand Down Expand Up @@ -110,6 +111,14 @@ public sealed override Task MatchAsync(HttpContext httpContext)
? ((Span<CandidateState>)candidateStateStackArray)[..candidateCount]
: (candidateStateArray = new CandidateState[candidateCount]);

// Matching and constraints still operate on Request.Path. RawTarget is only used
// when materializing captured values so encoded '/' can be decoded once without
// changing which endpoint wins.
PathTokenizer rawPathTokenizer = default;
var rawSegmentOffset = 0;
var useRawText = path.Contains('%') &&
RawTargetRouteValueDecoder.TryGetPathTokenizer(httpContext, out rawPathTokenizer, out rawSegmentOffset);

for (var i = 0; i < candidateCount; i++)
{
// PERF: using ref here to avoid copying around big structs.
Expand Down Expand Up @@ -140,12 +149,12 @@ public sealed override Task MatchAsync(HttpContext httpContext)

if ((flags & Candidate.CandidateFlags.HasCaptures) != 0)
{
ProcessCaptures(slots, candidate.Captures, path, segments);
ProcessCaptures(slots, candidate.Captures, path, segments, rawPathTokenizer, rawSegmentOffset, useRawText);
}

if ((flags & Candidate.CandidateFlags.HasCatchAll) != 0)
{
ProcessCatchAll(slots, candidate.CatchAll, path, segments);
ProcessCatchAll(slots, candidate.CatchAll, path, segments, rawPathTokenizer, rawSegmentOffset, useRawText);
}

state.Values = RouteValueDictionary.FromArray(slots);
Expand Down Expand Up @@ -235,7 +244,10 @@ private static void ProcessCaptures(
KeyValuePair<string, object?>[] slots,
(string parameterName, int segmentIndex, int slotIndex)[] captures,
string path,
ReadOnlySpan<PathSegment> segments)
ReadOnlySpan<PathSegment> segments,
PathTokenizer rawPathTokenizer,
int rawSegmentOffset,
bool useRawText)
{
for (var i = 0; i < captures.Length; i++)
{
Expand All @@ -246,9 +258,19 @@ private static void ProcessCaptures(
var segment = segments[segmentIndex];
if (parameterName != null && segment.Length > 0)
{
var value = path.Substring(segment.Start, segment.Length);
if (useRawText)
{
var rawSegmentIndex = segmentIndex + rawSegmentOffset;
if ((uint)rawSegmentIndex < (uint)rawPathTokenizer.Count)
{
value = RawTargetRouteValueDecoder.Decode(rawPathTokenizer[rawSegmentIndex]);
}
}

slots[slotIndex] = new KeyValuePair<string, object?>(
parameterName,
path.Substring(segment.Start, segment.Length));
value);
}
}
}
Expand All @@ -258,17 +280,34 @@ private static void ProcessCatchAll(
KeyValuePair<string, object?>[] slots,
in (string parameterName, int segmentIndex, int slotIndex) catchAll,
string path,
ReadOnlySpan<PathSegment> segments)
ReadOnlySpan<PathSegment> segments,
PathTokenizer rawPathTokenizer,
int rawSegmentOffset,
bool useRawText)
{
// Read segmentIndex to local both to skip double read from stack value
// and to use the same in-bounds validated variable to access the array.
var segmentIndex = catchAll.segmentIndex;
if ((uint)segmentIndex < (uint)segments.Length)
{
var segment = segments[segmentIndex];
var value = path.Substring(segment.Start);
if (useRawText)
{
var rawSegmentIndex = segmentIndex + rawSegmentOffset;
if ((uint)rawSegmentIndex < (uint)rawPathTokenizer.Count)
{
var rawSegment = rawPathTokenizer[rawSegmentIndex];
value = RawTargetRouteValueDecoder.Decode(new StringSegment(
rawSegment.Buffer!,
rawSegment.Offset,
rawSegment.Buffer!.Length - rawSegment.Offset));
}
}

slots[catchAll.slotIndex] = new KeyValuePair<string, object?>(
catchAll.parameterName,
path.Substring(segment.Start));
value);
}
}

Expand Down
53 changes: 53 additions & 0 deletions src/Http/Routing/src/Matching/RawTargetRouteValueDecoder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// 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;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.Primitives;

namespace Microsoft.AspNetCore.Routing.Matching;

internal static class RawTargetRouteValueDecoder
{
public static bool TryGetPathTokenizer(HttpContext httpContext, out PathTokenizer tokenizer, out int segmentOffset)
{
ArgumentNullException.ThrowIfNull(httpContext);

tokenizer = default;
segmentOffset = 0;

var rawTarget = httpContext.Features.Get<IHttpRequestFeature>()?.RawTarget;
if (string.IsNullOrEmpty(rawTarget) || rawTarget[0] != '/')
{
return false;
}

var queryIndex = rawTarget.IndexOf('?');
var rawPath = queryIndex >= 0 ? rawTarget[..queryIndex] : rawTarget;
if (!rawPath.Contains('%'))
{
return false;
}

tokenizer = new PathTokenizer(new PathString(rawPath));
if (httpContext.Request.PathBase.HasValue)
{
// RawTarget still contains the original path base, so skip those segments
// when aligning raw segments with the matched route path.
segmentOffset = new PathTokenizer(httpContext.Request.PathBase).Count;
}

return true;
}

public static string Decode(StringSegment segment)
{
var value = segment.AsSpan();
if (!value.Contains('%'))
{
return segment.ToString();
}

return Uri.UnescapeDataString(segment.ToString());
}
}
80 changes: 77 additions & 3 deletions src/Http/Routing/src/Patterns/RoutePatternMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,41 @@ public RoutePatternMatcher(

public RoutePattern RoutePattern { get; }

#if !COMPONENTS
// This matcher is also compiled into the Components routing build, where there is
// no HttpContext or RawTarget. Keep the PathString-only path working there and only
// light up the RawTarget-aware overload for server-side routing.
public bool TryMatch(HttpContext httpContext, RouteValueDictionary values)
{
ArgumentNullException.ThrowIfNull(httpContext);

if (httpContext.Request.Path.Value?.Contains('%') != true)
{
return TryMatchCore(httpContext.Request.Path, values, httpContext: null);
}

return TryMatchCore(httpContext.Request.Path, values, httpContext);
}
#endif

public bool TryMatch(PathString path, RouteValueDictionary values)
{
return TryMatchCore(
path,
values
#if !COMPONENTS
, httpContext: null
#endif
);
}

private bool TryMatchCore(
PathString path,
RouteValueDictionary values
#if !COMPONENTS
, HttpContext httpContext
#endif
)
{
ArgumentNullException.ThrowIfNull(values);

Expand Down Expand Up @@ -144,10 +178,28 @@ public bool TryMatch(PathString path, RouteValueDictionary values)

// At this point we've very likely got a match, so start capturing values for real.
i = 0;
#if !COMPONENTS
// The match above already used PathString semantics. RawTarget is only consulted
// here so the captured route value can reflect the original encoded segment.
PathTokenizer rawPathTokenizer = default;
var rawSegmentIndex = 0;
var useRawText = httpContext != null && Matching.RawTargetRouteValueDecoder.TryGetPathTokenizer(httpContext, out rawPathTokenizer, out rawSegmentIndex);
#else
PathTokenizer rawPathTokenizer = default;
var rawSegmentIndex = 0;
const bool useRawText = false;
#endif
foreach (var requestSegment in pathTokenizer)
{
var pathSegment = RoutePattern.PathSegments[i++];
if (SavePathSegmentsAsValues(i, values, requestSegment, pathSegment))
var rawRequestSegment = default(StringSegment);
var hasRawRequestSegment = useRawText && (uint)rawSegmentIndex < (uint)rawPathTokenizer.Count;
if (hasRawRequestSegment)
{
rawRequestSegment = rawPathTokenizer[rawSegmentIndex++];
}

if (SavePathSegmentsAsValues(i, values, requestSegment, rawRequestSegment, hasRawRequestSegment, pathSegment))
{
break;
}
Expand Down Expand Up @@ -244,12 +296,27 @@ private bool TryMatchLiterals(int index, StringSegment stringSegment, RoutePatte
return true;
}

private bool SavePathSegmentsAsValues(int index, RouteValueDictionary values, StringSegment requestSegment, RoutePatternPathSegment pathSegment)
private bool SavePathSegmentsAsValues(
int index,
RouteValueDictionary values,
StringSegment requestSegment,
StringSegment rawRequestSegment,
bool useRawText,
RoutePatternPathSegment pathSegment)
{
if (pathSegment.IsSimple && pathSegment.Parts[0] is RoutePatternParameterPart parameter && parameter.IsCatchAll)
{
// A catch-all captures til the end of the string.
var captured = requestSegment.Buffer.Substring(requestSegment.Offset);
#if !COMPONENTS
if (useRawText)
{
captured = Matching.RawTargetRouteValueDecoder.Decode(new StringSegment(
rawRequestSegment.Buffer,
rawRequestSegment.Offset,
rawRequestSegment.Buffer.Length - rawRequestSegment.Offset));
}
#endif
if (captured.Length > 0)
{
values[parameter.Name] = captured;
Expand All @@ -270,7 +337,14 @@ private bool SavePathSegmentsAsValues(int index, RouteValueDictionary values, St
parameter = (RoutePatternParameterPart)pathSegment.Parts[0];
if (requestSegment.Length > 0)
{
values[parameter.Name] = requestSegment.ToString();
var captured = requestSegment.ToString();
#if !COMPONENTS
if (useRawText)
{
captured = Matching.RawTargetRouteValueDecoder.Decode(rawRequestSegment);
}
#endif
values[parameter.Name] = captured;
}
else
{
Expand Down
4 changes: 1 addition & 3 deletions src/Http/Routing/src/RouteBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,7 @@ public virtual Task RouteAsync(RouteContext context)
EnsureMatcher();
EnsureLoggers(context.HttpContext);

var requestPath = context.HttpContext.Request.Path;

if (!_matcher.TryMatch(requestPath, context.RouteData.Values))
if (!_matcher.TryMatch(context.HttpContext, context.RouteData.Values))
{
// If we got back a null value set, that means the URI did not match
return Task.CompletedTask;
Expand Down
8 changes: 8 additions & 0 deletions src/Http/Routing/src/Template/TemplateMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,12 @@ public bool TryMatch(PathString path, RouteValueDictionary values)

return _routePatternMatcher.TryMatch(path, values);
}

internal bool TryMatch(HttpContext httpContext, RouteValueDictionary values)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(values);

return _routePatternMatcher.TryMatch(httpContext, values);
}
}
2 changes: 1 addition & 1 deletion src/Http/Routing/src/Tree/TreeRouter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ public void Route(RouteContext context)
var matcher = item.TemplateMatcher;
try
{
if (!matcher.TryMatch(context.HttpContext.Request.Path, context.RouteData.Values))
if (!matcher.TryMatch(context.HttpContext, context.RouteData.Values))
{
continue;
}
Expand Down
51 changes: 51 additions & 0 deletions src/Http/Routing/test/UnitTests/Matching/DfaMatcherTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Routing.Constraints;
using Microsoft.AspNetCore.Routing.TestObjects;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -100,6 +101,56 @@ public async Task MatchAsync_InvalidRouteConstraint_NoEndpointMatched()
Assert.Null(httpContext.GetEndpoint());
}

[Theory]
[InlineData("/foo%2Fbar", "/foo%2Fbar", "foo/bar")]
[InlineData("/foo%2Fbar", "/foo%252Fbar", "foo%2Fbar")]
[InlineData("/http:%2F%2Fexample.com", "/http:%2F%2Fexample.com", "http://example.com")]
public async Task MatchAsync_UsesRawTargetToDecodeRouteValues(string path, string rawTarget, string expected)
{
// Arrange
var endpointDataSource = new DefaultEndpointDataSource(new List<Endpoint>
{
CreateEndpoint("/{value}", 0)
});

var matcher = CreateDfaMatcher(endpointDataSource);

var httpContext = CreateContext();
httpContext.Request.Path = path;
httpContext.Features.Get<IHttpRequestFeature>()!.RawTarget = rawTarget;

// Act
await matcher.MatchAsync(httpContext);

// Assert
Assert.NotNull(httpContext.GetEndpoint());
Assert.Equal(expected, httpContext.Request.RouteValues["value"]);
}

[Fact]
public async Task MatchAsync_UsesRawTargetToDecodeRouteValues_AfterPathBase()
{
// Arrange
var endpointDataSource = new DefaultEndpointDataSource(new List<Endpoint>
{
CreateEndpoint("/{value}", 0)
});

var matcher = CreateDfaMatcher(endpointDataSource);

var httpContext = CreateContext();
httpContext.Request.PathBase = "/base";
httpContext.Request.Path = "/foo%2Fbar";
httpContext.Features.Get<IHttpRequestFeature>()!.RawTarget = "/base/foo%2Fbar";

// Act
await matcher.MatchAsync(httpContext);

// Assert
Assert.NotNull(httpContext.GetEndpoint());
Assert.Equal("foo/bar", httpContext.Request.RouteValues["value"]);
}

[Theory]
[InlineData("{a}.{b}.{c}/{d}", "/.git/index")]
[InlineData("{a}-{b}-{c}/c.aspx", "/-hello/c.aspx")]
Expand Down
Loading
Loading