Skip to content

Avoid per-request allocations in ForwardedHeadersMiddleware - #70177

Open
davidfowl wants to merge 10 commits into
mainfrom
perf/forwardedheaders-apply
Open

davidfowl wants to merge 10 commits into
mainfrom
perf/forwardedheaders-apply

Conversation

@davidfowl

@davidfowl davidfowl commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

Avoid per-request allocations in ForwardedHeadersMiddleware

  • 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.

Cut per-request allocations in ForwardedHeadersMiddleware.ApplyForwarders.

Description

ApplyForwarders now allocates nothing for requests that carry no forwarded headers, and it allocates less for proxied requests.

ForwardedHeadersMiddleware.ApplyForwarders runs on every request. Before this change, it:

  • always allocated a SetOfForwarders[entryCount] array, even when entryCount == 0;
  • always allocated a new IPEndPoint(RemoteIpAddress, RemotePort) for the connection's own address. That endpoint is only ever used for its Address, for a debug log and to restore the same values.

This PR changes it so that:

  • Early return when entryCount == 0. Previously the loop body never ran in this case, so applyChanges stayed false and nothing was modified, which makes this exactly equivalent. Typical cases are health probes, direct traffic and internal calls.
  • Forwarder sets are built inline in the processing loop instead of into an array first. The values and right-to-left order are the same.
  • The current remote address is tracked as an IPAddress, with the last parsed X-Forwarded-For IPEndPoint kept separately. The Unknown proxy: {RemoteIpAndPort} debug log still receives an IPEndPoint with the same value; it is created only when that log is enabled. The final checkFor && currentRemote != null branch, X-Original-For and the restored RemoteIpAddress/RemotePort are unchanged. This includes the case where only the proto is applied: X-Original-For is still written and X-Forwarded-For is still consumed.
  • An out-of-range RemotePort (with a non-null address) still throws the same ArgumentOutOfRangeException from IPEndPoint as before.

Benchmark

I used a local BenchmarkDotNet harness (not included). It calls ApplyForwarders on a DefaultHttpContext with For | Proto | Host enabled and two KnownProxies. ContextOnly measures the context and request headers on their own. Timings are on 2 cores, 3 runs each, and allocations are per operation including the context.

Case Before (main) After Δ alloc (middleware only)
ContextOnly 258–277 ns, 1344 B 267–273 ns, 1344 B —
No forwarded headers 359–379 ns ±4–7, 1400 B 365–389 ns ±7–8, 1344 B 56 B → 0 B
X-Forwarded-For + X-Forwarded-Proto (1 hop) 765–782 ns ±14–16, 1904 B 706–751 ns ±9–14, 1808 B 560 B → 464 B (−17%)
3-hop For/Proto/Host chain 1175–1185 ns ±18–23, 2472 B 1158–1202 ns ±4–24, 2376 B 1128 B → 1032 B (−9%)

Timings are within noise; the change is about allocations.

Tests

  • Microsoft.AspNetCore.HttpOverrides.Tests: 502/502 pass.
  • New tests run directly against ApplyForwarders, and all of them also pass against main:
    • NoForwardedHeadersLeavesRequestUnchanged
    • InvalidRemotePortThrows (with and without forwarded headers)
    • ProtoOnlyStillRecordsOriginalForAndConsumesForwardedFor
    • KnownProxiesStopAtUnknownPeerAndLogCurrentEndpoint, which checks the exact Unknown proxy: ip:port log text for the original and forwarded endpoints, the remote IP and port, and the truncated X-Forwarded-For and X-Original-For values
    • NoRemoteAddressWithKnownProxiesAppliesNothing

Benchmark (3 interleaved rounds)

The benchmark is committed in src/Middleware/HttpOverrides/perf/Microbenchmarks/ForwardedHeadersMiddlewareBenchmark.cs. It calls ApplyForwarders on a DefaultHttpContext with For | Proto | Host enabled, two KnownProxies and ForwardLimit = null. ContextOnly measures creating the context on its own.

Result: allocations drop in every case that runs the middleware: −56 B with no forwarded headers (the middleware itself now allocates 0 B), −96 B for one hop and −176 B for the 3-hop chain. The time differences (−2% to −8%) are of the same order as the ContextOnly drift between builds (−3.6%), so I don't claim a time win. This is an allocation-only improvement.

Benchmark Round main Mean PR Mean Δ time main Allocated PR Allocated
ContextOnly 1 337.1 ns ±33.8 314.0 ns ±6.6 -6.9% 1,384 B 1,384 B
ContextOnly 2 301.6 ns ±11.9 298.2 ns ±11.3 -1.1% 1,384 B 1,384 B
ContextOnly 3 315.2 ns ±12.0 307.4 ns ±9.3 -2.5% 1,384 B 1,384 B
NoForwardedHeaders 1 421.6 ns ±20.8 405.7 ns ±10.0 -3.8% 1,440 B 1,384 B
NoForwardedHeaders 2 443.9 ns ±44.8 370.4 ns ±6.3 -16.6% 1,440 B 1,384 B
NoForwardedHeaders 3 401.5 ns ±14.6 383.1 ns ±8.8 -4.6% 1,440 B 1,384 B
ForwardedForAndProto 1 885.4 ns ±13.4 842.1 ns ±16.9 -4.9% 1,952 B 1,856 B
ForwardedForAndProto 2 930.6 ns ±95.5 803.9 ns ±10.8 -13.6% 1,952 B 1,856 B
ForwardedForAndProto 3 866.9 ns ±16.8 836.0 ns ±13.5 -3.6% 1,952 B 1,856 B
ThreeHopChain 1 1,686.0 ns ±21.0 1,656.0 ns ±35.0 -1.8% 2,640 B 2,464 B
ThreeHopChain 2 1,689.0 ns ±23.0 1,618.0 ns ±29.0 -4.2% 2,640 B 2,464 B
ThreeHopChain 3 1,670.0 ns ±15.0 1,653.0 ns ±24.0 -1.0% 2,640 B 2,464 B

BenchmarkDotNet v0.13.0, InProcess, 5 warmup + 15 measured iterations, Server GC. Rounds ran in the order main, PR, main, PR, main, PR. Both builds use the same benchmark source; "main" is this PR's merge base (4f556f7). Allocated is exact bytes per op from the GC counters. Machine: AMD EPYC 9V74, 2 cores, Ubuntu 24.04 (container), .NET SDK 12.0.100-alpha.1.26521.103, runtime 12.0.0 (12.0.26.52911).

🤖 Posted by ASP.NET Core Perf 2 (AI Agent)

…arders

Return early when there are no forwarded entries, build each forwarder set
inline instead of allocating a SetOfForwarders[] and track the current remote
address without allocating an IPEndPoint for the connection's own address.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings October 31, 2026 08:33
@github-actions github-actions Bot added the area-middleware Includes: URL rewrite, redirect, response cache/compression, session, and other general middlewares label Oct 31, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The missing frozen review bundle, including revision-bound base sources and existing feedback, prevented completion of compatibility and feedback-deduplication checks.

No open issues 🎉

What changed in this PR

This PR reduces per-request allocations in ForwardedHeadersMiddleware while preserving forwarded-header processing, endpoint logging, and invalid-port validation.

Changes:

  • Returns early when there are no forwarded entries to process.
  • Builds forwarder sets inline and avoids allocating an endpoint for the initial connection address.
  • Adds regression tests for unchanged requests, invalid ports, proto-only forwarding, proxy-chain logging, and missing remote addresses.
File Description
src/​Middleware/​HttpOverrides/​test/​ForwardedHeadersMiddlewareTest.cs Adds regression coverage for behavior preserved by the allocation changes.
src/​Middleware/​HttpOverrides/​src/​ForwardedHeadersMiddleware.cs Removes intermediate allocations and separates remote-address tracking from parsed endpoints.

Review effort: Balanced

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copy link
Copy Markdown
Member Author

Pushed empty commit 4ce46d2 to retrigger CI. The failure was in aspnetcore-components-e2e (Blazor E2E on Linux, Mono server): Assert.Equal() Failure: Expected http://127.0.0.1:37943//myexternalpath3, Actual http://127.0.0.1:37943/subdir/. That's the known NavigationLock_CanBlockExternalNavigation flake (~2/25 recent runs on unrelated PRs). This PR only touches src/Middleware/HttpOverrides.

🤖 Posted by ASP.NET Core Perf 2 (AI Agent)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copy link
Copy Markdown
Member Author

Pushed empty commit 2ec2037 to retrigger CI. The only failure is again ServerRoutingTest.NavigationLock_CanBlockExternalNavigation in aspnetcore-components-e2e (Mono server leg; expected //myexternalpath3, got /subdir/). The same test fails right now on unrelated PRs: it appears in the e2e annotations of #70107 and #67015 (twice), plus my unrelated #70173 and #70176, so roughly 5 of the last ~15 e2e runs I sampled. This PR only touches src/Middleware/HttpOverrides, which the Blazor E2E test app doesn't use.

🤖 Posted by ASP.NET Core Perf 2 (AI Agent)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copy link
Copy Markdown
Member Author

aspnetcore-ci passed. The only failures were in aspnetcore-components-e2e, so I pushed empty commit 2592e5c to retrigger:

This PR only touches ForwardedHeadersMiddleware. Components and Blazor aren't involved.

🤖 Posted by ASP.NET Core Perf 2 (AI Agent)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copy link
Copy Markdown
Member Author

The Blazor e2e and quarantined pipelines passed this time. aspnetcore-ci failed only on one Helix test: Microsoft.AspNetCore.Server.Kestrel.Core.Tests.Http2StreamTests.OnDynamicIndexedHeader_NewlineCharactersInValue_ThrowsConnectionError (InMemory.FunctionalTests). This PR only touches src/Middleware/HttpOverrides (ForwardedHeadersMiddleware plus its tests), and Kestrel doesn't reference it. Locally, the test passed 15/15 (5 runs × 3 cases), so I'm treating it as a one-off flake (1 failure in the ~10 aspnetcore-ci runs I've seen today). I pushed empty commit f80ad30 to retrigger.

🤖 Posted by ASP.NET Core Perf 2 (AI Agent)

Copy link
Copy Markdown
Member Author

/azp run aspnetcore-components-e2e

🤖 Posted by ASP.NET Core Perf 2 (AI Agent)

@azure-pipelines

Copy link
Copy Markdown
No pipelines are associated with this pull request.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copy link
Copy Markdown
Member Author

/azp run answers "No pipelines are associated" for this account, so I pushed empty commit 9eca4c4 instead. On the previous run aspnetcore-ci and aspnetcore-quarantined-pr passed. Only the (Mono client and WebAssembly) e2e leg failed, on VirtualizationTest.VariableHeight_HtmlTable_DoesNotProduceNestedTrElements and RedirectionTest.NavigationException_InAsyncContext_DoesNotBecomeUnobservedTaskException. Those same two tests also fail intermittently on #70181, #69752 and #70068. Nothing in src/Components uses HttpOverrides.

🤖 Posted by ASP.NET Core Perf 2 (AI Agent)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copy link
Copy Markdown
Member Author

On 9eca4c4 the Blazor e2e and quarantined pipelines passed. aspnetcore-ci failed only on Mvc.FunctionalTests.RealServerUsingMinimalBackedIntegrationTests.CanResolveServices (IServerAddressesFeature.Addresses cannot be modified after the server has started). That's a known Kestrel startup-race flake, previously quarantined in #67340. Nothing in src/Mvc/test uses ForwardedHeaders. I pushed empty commit eb5706d to retrigger.

🤖 Posted by ASP.NET Core Perf 2 (AI Agent)

@PureWeen

PureWeen commented Nov 1, 2026

Copy link
Copy Markdown
Member

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Source-only review of frozen head eb5706d73c63c82649b0565fd0007549ed51cbb6 found one new P2 compatibility regression: a negative ForwardLimit now silently disables forwarding and allows downstream request processing instead of failing before dispatch.

The routed docs/CrossCuttingGuidance.md review completed with gpt-5.6-sol (runtime effort not supplied/default), covering compatibility/API, correctness and validation, allocation behavior, options/DI, diagnostics, nullability, lifecycle, threading, portability, packaging, formatting, and test false-pass risk. No routed guide was incomplete; unresolved candidates: none. No prepared feedback duplicated this finding; the existing Copilot review reported no open issues because its bundle was unavailable.

Provenance: version-2 ready bundle for PR 70177, merge base/base tip 4f556f73a610b386e5ec29355a300e493d30c4c5, and reviewer guidance from immutable workflow commit 4f556f73a610b386e5ec29355a300e493d30c4c5. Proof is source-only. Running PR code, tests, CI, browser workflows, and security assessment were excluded by the trusted bundle; no execution claims are made. HttpOverrides had no specialist area guide beyond the completed cross-cutting review. Limitations are tracked Git bytes only; external symlink, submodule, and LFS target behavior is not established.

Generated by ASP.NET Core Pull Request Review for #70177 · copilot · gpt56 · 163.4 AIC · ⌖ 7.6 AIC · ⊞ 20.2K
Comment /review to run again

davidfowl and others added 2 commits November 1, 2026 18:09
…ersMiddleware

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds a Microsoft.AspNetCore.HttpOverrides.Microbenchmarks project that
calls ApplyForwarders with no forwarded headers, a single
X-Forwarded-For/Proto hop and a 3-hop For/Proto/Host chain.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@davidfowl davidfowl added the agent:working An AI agent is actively working on this issue/PR label Nov 6, 2026 — with Copilot SWE Agent
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@davidfowl davidfowl removed the agent:working An AI agent is actively working on this issue/PR label Nov 6, 2026
@davidfowl davidfowl added the agent:working An AI agent is actively working on this issue/PR label Nov 6, 2026 — with Copilot SWE Agent

Copy link
Copy Markdown
Member Author

CI triage for head 77a200b: the three failing tests are unrelated to this change, which only touches ForwardedHeadersMiddleware, its unit tests and a new microbenchmark project.

All other aspnetcore-ci jobs passed, as did the CoreCLR and WebAssembly E2E legs.

🤖 Posted by ASP.NET Core Perf 2 (AI Agent)

@davidfowl davidfowl removed the agent:working An AI agent is actively working on this issue/PR label Nov 6, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-middleware Includes: URL rewrite, redirect, response cache/compression, session, and other general middlewares

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants