Fix NegotiationMatcherPolicy invalidating higher-priority endpoints#65973
Fix NegotiationMatcherPolicy invalidating higher-priority endpoints#65973
Conversation
0e05397 to
f7ebd33
Compare
There was a problem hiding this comment.
Pull request overview
This PR addresses a routing correctness issue where NegotiationMatcherPolicy<T>.ApplyAsync (used for Accept-Encoding negotiation) can incorrectly invalidate higher-priority endpoints when a lower-priority endpoint matches an encoding, causing catch-all endpoints to override more-specific routes.
Changes:
- Update
NegotiationMatcherPolicy<T>.ApplyAsync/EvaluateCandidateto avoid invalidating candidates across “priority tiers”. - Add extensive unit tests for
ContentEncodingNegotiationMatcherPolicy.ApplyAsynccovering priority preservation scenarios. - Add end-to-end integration tests for both the
INodeBuilderPolicyandIEndpointSelectorPolicypaths.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Http/Routing/test/UnitTests/Matching/ContentEncodingNegotiationMatcherPolicyTest.cs | Adds many ApplyAsync unit tests plus a CandidateSet helper overload to model scores. |
| src/Http/Routing/test/UnitTests/Matching/ContentEncodingNegotiationMatcherPolicyIntegrationTestBase.cs | New shared end-to-end test suite for content-encoding negotiation behavior. |
| src/Http/Routing/test/UnitTests/Matching/ContentEncodingNegotiationMatcherPolicyINodeBuilderPolicyIntegrationTest.cs | Runs the integration suite via the INodeBuilderPolicy path (no dynamic metadata). |
| src/Http/Routing/test/UnitTests/Matching/ContentEncodingNegotiationMatcherPolicyIEndpointSelectorPolicyIntegrationTest.cs | Runs the integration suite via the selector-policy path (dynamic metadata present). |
| src/Http/Routing/src/Matching/NegotiationMatcherPolicy.cs | Core behavior change: introduces tier tracking and bounds candidate invalidation. |
...outing/test/UnitTests/Matching/ContentEncodingNegotiationMatcherPolicyIntegrationTestBase.cs
Show resolved
Hide resolved
NegotiationMatcherPolicy.ApplyAsync has two invalidation points that do
not account for routing priority (CandidateState.Score). When a
lower-priority endpoint (e.g. a catch-all with ContentEncodingMetadata)
matches the Accept-Encoding header, it blindly invalidates all preceding
candidates — including higher-priority endpoints representing different
resources.
This manifests when MapStaticAssets registers catch-all endpoints (e.g.
{**path:nonfile} for SPA fallback) with gzip/br/identity variants. A
request to a specific API or configuration endpoint (e.g.
_blazor/_configuration) that shares a DFA path node with these catch-alls
gets incorrectly overridden by the catch-all's gzip variant.
The fix leverages the fact that candidates are already sorted by
ascending Score. A scoreTierStart variable tracks where the current
score tier begins. Backward sweeps in EvaluateCandidate use
Math.Max(bestMatchIndex, scoreTierStart) as the start bound, and
post-match invalidation skips candidates with strictly lower score
than the best match. This preserves higher-priority endpoints without
adding per-element Score comparisons.
End-to-end tests that exercise the full DFA routing pipeline with real RouteEndpoint instances and route patterns. Tests run through both the INodeBuilderPolicy path (DFA branch construction) and the IEndpointSelectorPolicy path (runtime candidate filtering). Key scenarios: - Basic encoding negotiation (gzip/br/identity selection) - Literal endpoint preserved when catch-all has encoding metadata - Multiple catch-all encoding variants don't defeat literal endpoints - Parameterized endpoints preserved over catch-alls with metadata - Mixed priority tiers (literal > parameterized > catch-all) - Static file encoding variants with catch-all present - 406 when no encoding matches and no identity fallback
9eb9c30 to
ca5edea
Compare
- Use separate currentTierScore variable instead of candidates[scoreTierStart].Score to avoid reading bitwise-negated scores after SetValidity invalidates candidates - Add Match_ContentEncoding_AcceptQualityWins_WhenBothAcceptAndEndpointQualityDiffer test Agent-Logs-Url: https://github.com/dotnet/aspnetcore/sessions/133133cd-c173-4ceb-97d3-1524b005fc2e Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com>
| } | ||
|
|
||
| [Fact] | ||
| public async Task ApplyAsync_PreservesHigherPriorityEndpoint_PostMatchInvalidation() |
There was a problem hiding this comment.
Why do we test it if
Candidates are already sorted by ascending
Score
?
If we really want to have incorrect order working, we should cover it a bit more. We have 2 mechanisms that check best match:
- post-match guard
candidates[i].Score < bestMatchScore - tier-tracking
Math.Max(bestMatchIndex, scoreTierStart)
This test checks mechanism 1), we don't have coverage for mechanism 2). If we already check non-ascending then let's check something that would hit both code changes.
We could have e.g.
var endpoints = CreateCandidateSet(
new[] { gzip, br, zstd },
new[] { 0, 1, 0 });
....
// gzip;q=0.3, br;q=0.5, zstd;q=0.8 zstd wins
Assert.False(endpoints.IsValidCandidate(0));
Assert.False(endpoints.IsValidCandidate(1));
Assert.True(endpoints.IsValidCandidate(2));
| } | ||
|
|
||
| [Fact] | ||
| public async Task ApplyAsync_PreservesHigherPriority_ParametersAtDifferentSegmentPositions() |
There was a problem hiding this comment.
This test combines what is already done by MixedRoutePriorities + DifferentMetadataValuesSameTier. It doesn't actually test different segment positions, it would have to be e2e to have route templates.
| } | ||
|
|
||
| [Fact] | ||
| public async Task ApplyAsync_PreservesAllHigherPriorityEndpoints_MixedRoutePriorities() |
There was a problem hiding this comment.
Very similar to PreservesHigherPriorityEndpoint_WhenLowerPriorityEndpointMatchesEncoding, it tests 5 tiers instead of 2. Should we merge them?
Fix
NegotiationMatcherPolicy<T>.ApplyAsyncinvalidating higher-priority endpoints when a lower-priority endpoint has matching negotiation metadata (e.g.ContentEncodingMetadata).Description
NegotiationMatcherPolicy<T>.ApplyAsyncinvalidates higher-priority endpoints when a lower-priority endpoint has matching negotiation metadata (e.g.ContentEncodingMetadata). Content-encoding negotiation should only disambiguate between representations of the same resource, not override the routing system's priority-based selection across different resources.The Problem
When a catch-all endpoint (e.g.
{**path}) carriesContentEncodingMetadataand shares a DFA path node with a higher-priority literal endpoint that has no encoding metadata,ApplyAsyncincorrectly invalidates the literal endpoint.This happens because
GetEdgescorrectly adds endpoints without encoding metadata to all DFA branches as "parent" endpoints. So the literal endpoint appears in the gzip branch alongside the catch-all's gzip variant. WhenApplyAsyncfinds the catch-all as an encoding match, it blindly invalidates all preceding candidates — including the higher-priority literal endpoint that represents a completely different resource.Root Cause
ApplyAsynchas two invalidation points that don't account for routing priority (CandidateState.Score):Point 1 —
EvaluateCandidatebackward sweep (when a higher-quality encoding match is found):Point 2 — Post-match invalidation (when a candidate doesn't match and a best match already exists):
Neither point checks whether the candidate being invalidated has better routing priority (lower Score) than the one being promoted. A catch-all with
Score=1defeats a literal endpoint withScore=0.The Fix
Candidates are already sorted by ascending
Score(fromDfaMatcherBuilder.ApplyPoliciesusingEndpointComparer). The fix leverages this sorted order:scoreTierStartandcurrentTierScoretracking — The outer loop tracks where the current score tier begins and stores the tier's score value in a separatecurrentTierScorevariable. This is necessary becauseSetValiditybitwise-negatesCandidateState.Scorewhen marking candidates invalid; readingcandidates[scoreTierStart].Scoreafter invalidation would corrupt the tier boundary. Using a dedicatedcurrentTierScorevariable avoids this.Bounded backward sweeps —
EvaluateCandidate's backward sweeps useMath.Max(bestMatchIndex, scoreTierStart)as the start bound, skipping all higher-priority score tiers entirely without per-element Score comparisons.Post-match guard — The post-match invalidation checks
candidates[i].Score < bestMatchScorebefore invalidating, preserving higher-priority endpoints that represent different resources.This ensures content-encoding negotiation only disambiguates within the same score tier (same routing priority), preserving the routing system's priority ordering across different resources.
Tests
ContentEncodingNegotiationMatcherPolicy.ApplyAsynccovering priority preservation scenarios.INodeBuilderPolicyandIEndpointSelectorPolicypaths.Match_ContentEncoding_AcceptQualityWins_WhenBothAcceptAndEndpointQualityDiffer: verifies that when two encoding variants of the same route have the same endpoint quality (tie-breaker), the client'sAccept-Encodingpreference correctly determines the winner. Covers both theINodeBuilderPolicyandIEndpointSelectorPolicy(HasDynamicMetadata == true) paths.✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.