From 66c34a5e4bf468c7a6368e384c769c28e0cd79b2 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Tue, 22 Jul 2025 14:07:04 +1200 Subject: [PATCH 1/4] fix: Sentry.Tunnel overwrites the X-Forwarded-For Resolves #1309: - https://github.com/getsentry/sentry-dotnet/issues/1309 --- .../SentryTunnelMiddleware.cs | 6 ++- .../Tunnel/IntegrationsTests.cs | 43 ++++++++++++++++++- .../Tunnel/MockHttpMessageHandler.cs | 4 +- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/Sentry.AspNetCore/SentryTunnelMiddleware.cs b/src/Sentry.AspNetCore/SentryTunnelMiddleware.cs index 2278f61a01..e13a53b4ea 100644 --- a/src/Sentry.AspNetCore/SentryTunnelMiddleware.cs +++ b/src/Sentry.AspNetCore/SentryTunnelMiddleware.cs @@ -98,10 +98,14 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) Method = HttpMethod.Post, Content = new StreamContent(memoryStream), }; + var existingForwardedFor = context.Request.Headers["X-Forwarded-For"]; var clientIp = context.Connection?.RemoteIpAddress?.ToString(); if (clientIp != null) { - sentryRequest.Headers.Add("X-Forwarded-For", context.Connection?.RemoteIpAddress?.ToString()); + var forwardedFor = string.IsNullOrEmpty(existingForwardedFor) + ? clientIp + : $"{existingForwardedFor}, {clientIp}"; + sentryRequest.Headers.Add("X-Forwarded-For", forwardedFor); } var responseMessage = await client.SendAsync(sentryRequest).ConfigureAwait(false); // We send the response back to the client, whatever it was diff --git a/test/Sentry.AspNetCore.Tests/Tunnel/IntegrationsTests.cs b/test/Sentry.AspNetCore.Tests/Tunnel/IntegrationsTests.cs index 797b40b922..7f8d4c4808 100644 --- a/test/Sentry.AspNetCore.Tests/Tunnel/IntegrationsTests.cs +++ b/test/Sentry.AspNetCore.Tests/Tunnel/IntegrationsTests.cs @@ -1,4 +1,6 @@ +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; @@ -9,6 +11,7 @@ public class IntegrationsTests : IDisposable private readonly TestServer _server; private HttpClient _httpClient; private MockHttpMessageHandler _httpMessageHandler; + private const string FakeRequestIp = "192.168.200.200"; public IntegrationsTests() { @@ -22,7 +25,16 @@ public IntegrationsTests() factory.CreateClient(Arg.Any()).Returns(_httpClient); s.AddSingleton(factory); }) - .Configure(app => { app.UseSentryTunneling(); }); + .Configure(app => + { + app.Use((context, next) => + { + // The context doesn't get sent by TestServer automatically... so we fake a remote request here + context.Connection.RemoteIpAddress = IPAddress.Parse(FakeRequestIp); + return next(); + }); + app.UseSentryTunneling(); + }); _server = new TestServer(builder); } @@ -86,6 +98,35 @@ public async Task TunnelMiddleware_CanForwardEnvelopeToWhiteListedHost() Assert.Equal(1, _httpMessageHandler.NumberOfCalls); } + [Fact] + public async Task TunnelMiddleware_XForwardedFor_RetainsOriginIp() + { + // Arrange: Create a request with X-Forwarded-For header + var requestMessage = new HttpRequestMessage(new HttpMethod("POST"), "/tunnel") + { + Content = new StringContent( + @"{""sent_at"":""2021-01-01T00:00:00.000Z"",""sdk"":{""name"":""sentry.javascript.browser"",""version"":""6.8.0""},""dsn"":""https://dns@sentry.io/1""} +{""type"":""session""} +{""sid"":""fda00e933162466c849962eaea0cfaff""}") + }; + const string originalForwardedFor = "192.168.1.100, 10.0.0.1"; + requestMessage.Headers.Add("X-Forwarded-For", originalForwardedFor); + + // Act + await _server.CreateClient().SendAsync(requestMessage); + + // Assert + Assert.Equal(1, _httpMessageHandler.NumberOfCalls); + + var forwardedRequest = _httpMessageHandler.LastRequest; + Assert.NotNull(forwardedRequest); + + Assert.True(forwardedRequest.Headers.Contains("X-Forwarded-For")); + var forwardedForHeader = forwardedRequest.Headers.GetValues("X-Forwarded-For").FirstOrDefault(); + Assert.NotNull(forwardedForHeader); + forwardedForHeader.Should().Be($"{originalForwardedFor}, {FakeRequestIp}"); + } + public void Dispose() { _httpClient.Dispose(); diff --git a/test/Sentry.AspNetCore.Tests/Tunnel/MockHttpMessageHandler.cs b/test/Sentry.AspNetCore.Tests/Tunnel/MockHttpMessageHandler.cs index b55fd29498..2a81b29a56 100644 --- a/test/Sentry.AspNetCore.Tests/Tunnel/MockHttpMessageHandler.cs +++ b/test/Sentry.AspNetCore.Tests/Tunnel/MockHttpMessageHandler.cs @@ -7,6 +7,7 @@ public class MockHttpMessageHandler : DelegatingHandler public string Input { get; private set; } public int NumberOfCalls { get; private set; } + public HttpRequestMessage LastRequest { get; private set; } public MockHttpMessageHandler(string response, HttpStatusCode statusCode) { @@ -18,9 +19,10 @@ protected override async Task SendAsync(HttpRequestMessage CancellationToken cancellationToken) { NumberOfCalls++; + LastRequest = request; if (request.Content != null) // Could be a GET-request without a body { - Input = await request.Content.ReadAsStringAsync(); + Input = await request.Content.ReadAsStringAsync(cancellationToken); } return new HttpResponseMessage { From db3b9ec9f436469c1c084ccfa163892dc7fb6772 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Tue, 22 Jul 2025 14:16:13 +1200 Subject: [PATCH 2/4] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6b899e623..795beceb4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - Native AOT: don't load SentryNative on unsupported platforms ([#4347](https://github.com/getsentry/sentry-dotnet/pull/4347)) +- SentryTunnelMiddleware overwrites the X-Forwarded-For header ([#4375](https://github.com/getsentry/sentry-dotnet/pull/4375)) ### Dependencies From f413b79eb49fae6e9cd3abb60fc95ea10b018839 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Fri, 25 Jul 2025 10:15:55 +1200 Subject: [PATCH 3/4] Tidy tests --- .../Tunnel/IntegrationsTests.cs | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/test/Sentry.AspNetCore.Tests/Tunnel/IntegrationsTests.cs b/test/Sentry.AspNetCore.Tests/Tunnel/IntegrationsTests.cs index 7f8d4c4808..6b8741e186 100644 --- a/test/Sentry.AspNetCore.Tests/Tunnel/IntegrationsTests.cs +++ b/test/Sentry.AspNetCore.Tests/Tunnel/IntegrationsTests.cs @@ -47,9 +47,11 @@ public async Task TunnelMiddleware_CanForwardValidEnvelope(string host) var requestMessage = new HttpRequestMessage(new HttpMethod("POST"), "/tunnel") { Content = new StringContent( - @"{""sent_at"":""2021-01-01T00:00:00.000Z"",""sdk"":{""name"":""sentry.javascript.browser"",""version"":""6.8.0""},""dsn"":""https://dns@" + host + @"/1""} -{""type"":""session""} -{""sid"":""fda00e933162466c849962eaea0cfaff""}") + $$""" + {"sent_at":"2021-01-01T00:00:00.000Z","sdk":{"name":"sentry.javascript.browser","version":"6.8.0"},"dsn":"https://dns@{{host}}/1"} + {"type":"session"} + {"sid":"fda00e933162466c849962eaea0cfaff"} + """) }; await _server.CreateClient().SendAsync(requestMessage); @@ -61,9 +63,12 @@ public async Task TunnelMiddleware_DoesNotForwardEnvelopeWithoutDsn() { var requestMessage = new HttpRequestMessage(new HttpMethod("POST"), "/tunnel") { - Content = new StringContent(@"{} -{""type"":""session""} -{""sid"":""fda00e933162466c849962eaea0cfaff""}") + Content = new StringContent( + """ + {} + {"type":"session"} + {"sid":"fda00e933162466c849962eaea0cfaff"} + """) }; await _server.CreateClient().SendAsync(requestMessage); @@ -75,9 +80,11 @@ public async Task TunnelMiddleware_DoesNotForwardEnvelopeToArbitraryHost() { var requestMessage = new HttpRequestMessage(new HttpMethod("POST"), "/tunnel"); requestMessage.Content = new StringContent( - @"{""sent_at"":""2021-01-01T00:00:00.000Z"",""sdk"":{""name"":""sentry.javascript.browser"",""version"":""6.8.0""},""dsn"":""https://dns@evil.com/1""} -{""type"":""session""} -{""sid"":""fda00e933162466c849962eaea0cfaff""}"); + """ + {"sent_at":"2021-01-01T00:00:00.000Z","sdk":{"name":"sentry.javascript.browser","version":"6.8.0"},"dsn":"https://dns@evil.com/1"} + {"type":"session"} + {"sid":"fda00e933162466c849962eaea0cfaff"} + """); await _server.CreateClient().SendAsync(requestMessage); Assert.Equal(0, _httpMessageHandler.NumberOfCalls); @@ -89,9 +96,11 @@ public async Task TunnelMiddleware_CanForwardEnvelopeToWhiteListedHost() var requestMessage = new HttpRequestMessage(new HttpMethod("POST"), "/tunnel") { Content = new StringContent( - @"{""sent_at"":""2021-01-01T00:00:00.000Z"",""sdk"":{""name"":""sentry.javascript.browser"",""version"":""6.8.0""},""dsn"":""https://dns@sentry.mywebsite.com/1""} -{""type"":""session""} -{""sid"":""fda00e933162466c849962eaea0cfaff""}") + """ + {"sent_at":"2021-01-01T00:00:00.000Z","sdk":{"name":"sentry.javascript.browser","version":"6.8.0"},"dsn":"https://dns@sentry.mywebsite.com/1"} + {"type":"session"} + {"sid":"fda00e933162466c849962eaea0cfaff"} + """) }; await _server.CreateClient().SendAsync(requestMessage); @@ -105,9 +114,11 @@ public async Task TunnelMiddleware_XForwardedFor_RetainsOriginIp() var requestMessage = new HttpRequestMessage(new HttpMethod("POST"), "/tunnel") { Content = new StringContent( - @"{""sent_at"":""2021-01-01T00:00:00.000Z"",""sdk"":{""name"":""sentry.javascript.browser"",""version"":""6.8.0""},""dsn"":""https://dns@sentry.io/1""} -{""type"":""session""} -{""sid"":""fda00e933162466c849962eaea0cfaff""}") + """ + {"sent_at":"2021-01-01T00:00:00.000Z","sdk":{"name":"sentry.javascript.browser","version":"6.8.0"},"dsn":"https://dns@sentry.io/1"} + {"type":"session"} + {"sid":"fda00e933162466c849962eaea0cfaff"} + """) }; const string originalForwardedFor = "192.168.1.100, 10.0.0.1"; requestMessage.Headers.Add("X-Forwarded-For", originalForwardedFor); From b3312243826b8c9b16639436f5232b6a8d5f621f Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Fri, 25 Jul 2025 10:42:24 +1200 Subject: [PATCH 4/4] Review feedback --- .../SentryTunnelMiddleware.cs | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/Sentry.AspNetCore/SentryTunnelMiddleware.cs b/src/Sentry.AspNetCore/SentryTunnelMiddleware.cs index e13a53b4ea..f65998d030 100644 --- a/src/Sentry.AspNetCore/SentryTunnelMiddleware.cs +++ b/src/Sentry.AspNetCore/SentryTunnelMiddleware.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Primitives; using Sentry.Internal.Extensions; namespace Sentry.AspNetCore; @@ -98,13 +99,8 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) Method = HttpMethod.Post, Content = new StreamContent(memoryStream), }; - var existingForwardedFor = context.Request.Headers["X-Forwarded-For"]; - var clientIp = context.Connection?.RemoteIpAddress?.ToString(); - if (clientIp != null) + if (CreateXForwardedForHeader(context) is { } forwardedFor) { - var forwardedFor = string.IsNullOrEmpty(existingForwardedFor) - ? clientIp - : $"{existingForwardedFor}, {clientIp}"; sentryRequest.Headers.Add("X-Forwarded-For", forwardedFor); } var responseMessage = await client.SendAsync(sentryRequest).ConfigureAwait(false); @@ -126,6 +122,25 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) } } + private static string? CreateXForwardedForHeader(HttpContext context) + { + var existingForwardedFor = context.Request.Headers["X-Forwarded-For"]; + var clientIp = context.Connection?.RemoteIpAddress?.ToString(); + if (clientIp is null) + { + return existingForwardedFor.Count > 0 ? existingForwardedFor.ToString() : null; + } + + if (existingForwardedFor.Count == 0) + { + return clientIp; + } + + return string.IsNullOrEmpty(existingForwardedFor) + ? clientIp + : $"{existingForwardedFor}, {clientIp}"; + } + private bool IsHostAllowed(string host) => host.EndsWith(".sentry.io", StringComparison.OrdinalIgnoreCase) || host.Equals("sentry.io", StringComparison.OrdinalIgnoreCase) ||