Skip to content

Fix NegotiationMatcherPolicy invalidating higher-priority endpoints#65973

Open
javiercn wants to merge 3 commits intomainfrom
javiercn/routing-fix
Open

Fix NegotiationMatcherPolicy invalidating higher-priority endpoints#65973
javiercn wants to merge 3 commits intomainfrom
javiercn/routing-fix

Conversation

@javiercn
Copy link
Copy Markdown
Member

@javiercn javiercn commented Mar 25, 2026

Fix NegotiationMatcherPolicy<T>.ApplyAsync invalidating higher-priority endpoints when a lower-priority endpoint has matching negotiation metadata (e.g. ContentEncodingMetadata).

  • You've read the Contributor Guide and Code of Conduct.
  • You've included unit or integration tests for your change, where applicable.
  • You've included inline docs for your change, where applicable.
  • There's an open issue for the PR that you are making. If you'd like to propose a new feature or change, please open an issue to discuss the change or find an existing issue.

Description

NegotiationMatcherPolicy<T>.ApplyAsync invalidates 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}) carries ContentEncodingMetadata and shares a DFA path node with a higher-priority literal endpoint that has no encoding metadata, ApplyAsync incorrectly invalidates the literal endpoint.

This happens because GetEdges correctly 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. When ApplyAsync finds 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

ApplyAsync has two invalidation points that don't account for routing priority (CandidateState.Score):

Point 1 — EvaluateCandidate backward sweep (when a higher-quality encoding match is found):

for (var j = bestMatchIndex; j < currentIndex; j++)
{
    candidates.SetValidity(j, false);  // ← No priority check
}

Point 2 — Post-match invalidation (when a candidate doesn't match and a best match already exists):

if (!found && (bestMatchIndex >= 0 || metadata != DefaultNegotiationValue))
{
    candidates.SetValidity(i, false);  // ← No priority check
}

Neither point checks whether the candidate being invalidated has better routing priority (lower Score) than the one being promoted. A catch-all with Score=1 defeats a literal endpoint with Score=0.

The Fix

Candidates are already sorted by ascending Score (from DfaMatcherBuilder.ApplyPolicies using EndpointComparer). The fix leverages this sorted order:

  1. scoreTierStart and currentTierScore tracking — The outer loop tracks where the current score tier begins and stores the tier's score value in a separate currentTierScore variable. This is necessary because SetValidity bitwise-negates CandidateState.Score when marking candidates invalid; reading candidates[scoreTierStart].Score after invalidation would corrupt the tier boundary. Using a dedicated currentTierScore variable avoids this.

  2. Bounded backward sweepsEvaluateCandidate's backward sweeps use Math.Max(bestMatchIndex, scoreTierStart) as the start bound, skipping all higher-priority score tiers entirely without per-element Score comparisons.

  3. Post-match guard — The post-match invalidation checks candidates[i].Score < bestMatchScore before 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

  • Extensive unit tests for ContentEncodingNegotiationMatcherPolicy.ApplyAsync covering priority preservation scenarios.
  • End-to-end integration tests for both the INodeBuilderPolicy and IEndpointSelectorPolicy paths.
  • Added Match_ContentEncoding_AcceptQualityWins_WhenBothAcceptAndEndpointQualityDiffer: verifies that when two encoding variants of the same route have the same endpoint quality (tie-breaker), the client's Accept-Encoding preference correctly determines the winner. Covers both the INodeBuilderPolicy and IEndpointSelectorPolicy (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.

@github-actions github-actions bot added the needs-area-label Used by the dotnet-issue-labeler to label those issues which couldn't be triaged automatically label Mar 25, 2026
@javiercn javiercn added area-routing and removed needs-area-label Used by the dotnet-issue-labeler to label those issues which couldn't be triaged automatically labels Mar 25, 2026
@javiercn javiercn force-pushed the javiercn/routing-fix branch 2 times, most recently from 0e05397 to f7ebd33 Compare March 25, 2026 13:00
@javiercn javiercn marked this pull request as ready for review March 26, 2026 07:02
@javiercn javiercn requested a review from halter73 as a code owner March 26, 2026 07:02
@javiercn javiercn requested review from JamesNK and Copilot March 26, 2026 07:02
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 / EvaluateCandidate to avoid invalidating candidates across “priority tiers”.
  • Add extensive unit tests for ContentEncodingNegotiationMatcherPolicy.ApplyAsync covering priority preservation scenarios.
  • Add end-to-end integration tests for both the INodeBuilderPolicy and IEndpointSelectorPolicy paths.

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.

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
@javiercn javiercn force-pushed the javiercn/routing-fix branch from 9eb9c30 to ca5edea Compare March 28, 2026 15:11
Copy link
Copy Markdown
Member Author

@javiercn javiercn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot address my feedback

- 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>
@javiercn javiercn requested a review from a team March 30, 2026 10:16
}

[Fact]
public async Task ApplyAsync_PreservesHigherPriorityEndpoint_PostMatchInvalidation()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. post-match guard candidates[i].Score < bestMatchScore
  2. 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()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very similar to PreservesHigherPriorityEndpoint_WhenLowerPriorityEndpointMatchesEncoding, it tests 5 tiers instead of 2. Should we merge them?

@maraf maraf self-requested a review April 3, 2026 08:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants