From 879d83e88d020050b3b24437cdf676cbc173f702 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 9 May 2026 10:56:44 +0100 Subject: [PATCH 1/3] Harden redirect handler: buffer content, filter sensitive headers - Buffer request content before first send so 307/308 redirects use fresh ByteArrayContent instead of reusing a potentially consumed stream - Strip Host header on all redirects (must match new destination) - Strip Authorization header on cross-host redirects to prevent credential leakage - Preserve Authorization on same-host redirects --- .../Services/EgressEnvelopeHandler.cs | 28 +++++++++- .../Services/EgressEnvelopeHandlerTests.cs | 52 ++++++++++++++++++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/EgressEnvelopeHandler.cs b/backend/src/Taskdeck.Application/Services/EgressEnvelopeHandler.cs index 8e9ec706c..5296d5112 100644 --- a/backend/src/Taskdeck.Application/Services/EgressEnvelopeHandler.cs +++ b/backend/src/Taskdeck.Application/Services/EgressEnvelopeHandler.cs @@ -42,6 +42,18 @@ protected override async Task SendAsync( // against the egress allowlist. If auto-redirect is enabled, the handler only // sees the final response and the redirect check becomes ineffective. + // Buffer request content before the first send so it can be replayed on 307/308 redirects. + // After SendAsync, the original content stream may be consumed and non-seekable. + byte[]? bufferedContent = null; + string? contentType = null; + if (request.Content is not null) + { + bufferedContent = await request.Content.ReadAsByteArrayAsync(cancellationToken); + contentType = request.Content.Headers.ContentType?.ToString(); + } + + var originalHost = request.RequestUri?.Host; + var response = await base.SendAsync(request, cancellationToken); // Manually follow redirects, validating each target against the egress envelope @@ -81,12 +93,24 @@ protected override async Task SendAsync( Method = statusCode is 307 or 308 ? request.Method : HttpMethod.Get, }; - // 307/308 require preserving the original headers and body + // 307/308 require preserving the original body and safe headers if (statusCode is 307 or 308) { - redirectRequest.Content = request.Content; + if (bufferedContent is not null) + { + var newContent = new ByteArrayContent(bufferedContent); + if (contentType is not null) + newContent.Headers.TryAddWithoutValidation("Content-Type", contentType); + redirectRequest.Content = newContent; + } + + var isCrossHost = !string.Equals(originalHost, resolvedRedirectUri.Host, StringComparison.OrdinalIgnoreCase); foreach (var header in request.Headers) { + if (string.Equals(header.Key, "Host", StringComparison.OrdinalIgnoreCase)) + continue; + if (isCrossHost && string.Equals(header.Key, "Authorization", StringComparison.OrdinalIgnoreCase)) + continue; redirectRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); } } diff --git a/backend/tests/Taskdeck.Application.Tests/Services/EgressEnvelopeHandlerTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/EgressEnvelopeHandlerTests.cs index 14b19e0ce..0c2ce1574 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/EgressEnvelopeHandlerTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/EgressEnvelopeHandlerTests.cs @@ -184,7 +184,7 @@ public void EgressViolation_ToString_ContainsKey() } [Fact] - public async Task SendAsync_307Redirect_PreservesHeadersAndContent() + public async Task SendAsync_307Redirect_PreservesContentAndSafeHeaders() { var registry = CreateRegistry("trusted.example.com", "redirect-target.example.com"); var inner = new SingleRedirectHandler( @@ -206,9 +206,59 @@ public async Task SendAsync_307Redirect_PreservesHeadersAndContent() inner.LastReceivedRequest.Should().NotBeNull(); inner.LastReceivedRequest!.Method.Should().Be(HttpMethod.Post); inner.LastReceivedRequest.Content.Should().NotBeNull(); + var body = await inner.LastReceivedRequest.Content!.ReadAsStringAsync(); + body.Should().Be("{\"key\":\"value\"}"); inner.LastReceivedRequest.Headers.Contains("X-Custom").Should().BeTrue(); } + [Fact] + public async Task SendAsync_307CrossHostRedirect_StripsAuthorizationHeader() + { + var registry = CreateRegistry("origin.example.com", "other.example.com"); + var inner = new SingleRedirectHandler( + "https://other.example.com/continue", + System.Net.HttpStatusCode.TemporaryRedirect); + var handler = new EgressEnvelopeHandler(registry) + { + InnerHandler = inner + }; + using var invoker = new HttpMessageInvoker(handler); + + var request = new HttpRequestMessage(HttpMethod.Post, "https://origin.example.com/api"); + request.Content = new StringContent("data"); + request.Headers.Add("Authorization", "Bearer secret-token"); + request.Headers.Add("X-Safe", "kept"); + + var response = await invoker.SendAsync(request, CancellationToken.None); + + response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); + inner.LastReceivedRequest!.Headers.Contains("Authorization").Should().BeFalse(); + inner.LastReceivedRequest.Headers.Contains("X-Safe").Should().BeTrue(); + } + + [Fact] + public async Task SendAsync_307SameHostRedirect_PreservesAuthorizationHeader() + { + var registry = CreateRegistry("same.example.com"); + var inner = new SingleRedirectHandler( + "https://same.example.com/other-path", + System.Net.HttpStatusCode.TemporaryRedirect); + var handler = new EgressEnvelopeHandler(registry) + { + InnerHandler = inner + }; + using var invoker = new HttpMessageInvoker(handler); + + var request = new HttpRequestMessage(HttpMethod.Post, "https://same.example.com/api"); + request.Content = new StringContent("data"); + request.Headers.Add("Authorization", "Bearer keep-this"); + + var response = await invoker.SendAsync(request, CancellationToken.None); + + response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); + inner.LastReceivedRequest!.Headers.Contains("Authorization").Should().BeTrue(); + } + // --- Test Helpers --- private sealed class StubHandler : HttpMessageHandler From 4b5a442050301986a0906dbe29df69cefa92dc16 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 10 May 2026 18:34:40 +0100 Subject: [PATCH 2/3] Harden redirect replay safety --- .../Services/EgressEnvelopeHandler.cs | 113 +++++++-- .../Services/EgressEnvelopeHandlerTests.cs | 239 +++++++++++++++++- 2 files changed, 326 insertions(+), 26 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/EgressEnvelopeHandler.cs b/backend/src/Taskdeck.Application/Services/EgressEnvelopeHandler.cs index 5296d5112..fc46b56c6 100644 --- a/backend/src/Taskdeck.Application/Services/EgressEnvelopeHandler.cs +++ b/backend/src/Taskdeck.Application/Services/EgressEnvelopeHandler.cs @@ -11,6 +11,8 @@ namespace Taskdeck.Application.Services; /// public sealed class EgressEnvelopeHandler : DelegatingHandler { + internal const long MaxRedirectReplayContentBytes = 1_048_576; + private readonly IEgressRegistry _egressRegistry; private readonly ILogger? _logger; private readonly string? _sourceComponent; @@ -42,19 +44,9 @@ protected override async Task SendAsync( // against the egress allowlist. If auto-redirect is enabled, the handler only // sees the final response and the redirect check becomes ineffective. - // Buffer request content before the first send so it can be replayed on 307/308 redirects. - // After SendAsync, the original content stream may be consumed and non-seekable. - byte[]? bufferedContent = null; - string? contentType = null; - if (request.Content is not null) - { - bufferedContent = await request.Content.ReadAsByteArrayAsync(cancellationToken); - contentType = request.Content.Headers.ContentType?.ToString(); - } - - var originalHost = request.RequestUri?.Host; - - var response = await base.SendAsync(request, cancellationToken); + var replayContent = await PrepareReplayableContentAsync(request, cancellationToken); + var currentRequest = request; + var response = await base.SendAsync(currentRequest, cancellationToken); // Manually follow redirects, validating each target against the egress envelope var redirectCount = 0; @@ -64,7 +56,7 @@ protected override async Task SendAsync( var resolvedRedirectUri = redirectUri.IsAbsoluteUri ? redirectUri - : new Uri(request.RequestUri!, redirectUri); + : new Uri(currentRequest.RequestUri!, redirectUri); var redirectHost = resolvedRedirectUri.Host; @@ -79,43 +71,53 @@ protected override async Task SendAsync( _logger?.LogError( "EgressViolation: redirect to '{Host}' not in egress envelope. OriginalURI={OriginalUri}, RedirectURI={RedirectUri}, Source={Source}", - redirectHost, request.RequestUri, resolvedRedirectUri, _sourceComponent); + redirectHost, currentRequest.RequestUri, resolvedRedirectUri, _sourceComponent); throw new EgressViolationException(violation); } // Follow the redirect: create a new request preserving the method for 307/308 var statusCode = (int)response.StatusCode; + var previousRequest = currentRequest; var redirectRequest = new HttpRequestMessage { RequestUri = resolvedRedirectUri, - Version = request.Version, - Method = statusCode is 307 or 308 ? request.Method : HttpMethod.Get, + Version = previousRequest.Version, + Method = statusCode is 307 or 308 ? previousRequest.Method : HttpMethod.Get, }; // 307/308 require preserving the original body and safe headers if (statusCode is 307 or 308) { - if (bufferedContent is not null) + if (previousRequest.Content is not null) { - var newContent = new ByteArrayContent(bufferedContent); - if (contentType is not null) - newContent.Headers.TryAddWithoutValidation("Content-Type", contentType); - redirectRequest.Content = newContent; + if (replayContent is null) + { + response.Dispose(); + throw new InvalidOperationException( + $"Cannot replay request content across a 307/308 redirect because the content length is unknown or exceeds {MaxRedirectReplayContentBytes} bytes."); + } + + redirectRequest.Content = CreateReplayContent(replayContent); } - var isCrossHost = !string.Equals(originalHost, resolvedRedirectUri.Host, StringComparison.OrdinalIgnoreCase); - foreach (var header in request.Headers) + var isCrossOrigin = !IsSameOrigin(previousRequest.RequestUri, resolvedRedirectUri); + foreach (var header in previousRequest.Headers) { if (string.Equals(header.Key, "Host", StringComparison.OrdinalIgnoreCase)) continue; - if (isCrossHost && string.Equals(header.Key, "Authorization", StringComparison.OrdinalIgnoreCase)) + if (isCrossOrigin && IsSensitiveRedirectHeader(header.Key)) continue; redirectRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); } } + else + { + replayContent = null; + } response.Dispose(); + currentRequest = redirectRequest; response = await base.SendAsync(redirectRequest, cancellationToken); } @@ -163,6 +165,67 @@ private static bool IsRedirect(HttpResponseMessage response) var statusCode = (int)response.StatusCode; return statusCode is >= 300 and < 400; } + + private static async Task PrepareReplayableContentAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (request.Content is null) + return null; + + var contentLength = request.Content.Headers.ContentLength; + if (contentLength is null || contentLength > MaxRedirectReplayContentBytes) + return null; + + var headers = request.Content.Headers + .Where(header => !string.Equals(header.Key, "Content-Length", StringComparison.OrdinalIgnoreCase)) + .Select(header => new KeyValuePair(header.Key, header.Value.ToArray())) + .ToArray(); + + var content = await request.Content.ReadAsByteArrayAsync(cancellationToken); + if (content.LongLength > MaxRedirectReplayContentBytes) + { + throw new InvalidOperationException( + $"Request content exceeds the {MaxRedirectReplayContentBytes} byte redirect replay limit."); + } + + var replayContent = new ReplayableContent(content, headers); + request.Content = CreateReplayContent(replayContent); + return replayContent; + } + + private static ByteArrayContent CreateReplayContent(ReplayableContent replayContent) + { + var content = new ByteArrayContent(replayContent.Content); + foreach (var header in replayContent.Headers) + { + content.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + return content; + } + + private static bool IsSameOrigin(Uri? left, Uri right) + { + if (left is null) + return false; + + return string.Equals(left.Scheme, right.Scheme, StringComparison.OrdinalIgnoreCase) + && string.Equals(left.Host, right.Host, StringComparison.OrdinalIgnoreCase) + && left.Port == right.Port; + } + + private static bool IsSensitiveRedirectHeader(string header) + => string.Equals(header, "Authorization", StringComparison.OrdinalIgnoreCase) + || string.Equals(header, "Proxy-Authorization", StringComparison.OrdinalIgnoreCase) + || string.Equals(header, "Cookie", StringComparison.OrdinalIgnoreCase) + || string.Equals(header, "x-goog-api-key", StringComparison.OrdinalIgnoreCase) + || string.Equals(header, "x-api-key", StringComparison.OrdinalIgnoreCase) + || string.Equals(header, "api-key", StringComparison.OrdinalIgnoreCase) + || header.Contains("token", StringComparison.OrdinalIgnoreCase) + || header.Contains("secret", StringComparison.OrdinalIgnoreCase); + + private sealed record ReplayableContent(byte[] Content, IReadOnlyList> Headers); } /// diff --git a/backend/tests/Taskdeck.Application.Tests/Services/EgressEnvelopeHandlerTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/EgressEnvelopeHandlerTests.cs index 0c2ce1574..6b1a9c57c 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/EgressEnvelopeHandlerTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/EgressEnvelopeHandlerTests.cs @@ -198,6 +198,9 @@ public async Task SendAsync_307Redirect_PreservesContentAndSafeHeaders() var request = new HttpRequestMessage(HttpMethod.Post, "https://trusted.example.com/api"); request.Content = new StringContent("{\"key\":\"value\"}"); + request.Content.Headers.ContentEncoding.Add("gzip"); + request.Content.Headers.ContentLanguage.Add("en-US"); + request.Content.Headers.Add("X-Content-Signature", "sig-123"); request.Headers.Add("X-Custom", "preserved"); var response = await invoker.SendAsync(request, CancellationToken.None); @@ -208,11 +211,14 @@ public async Task SendAsync_307Redirect_PreservesContentAndSafeHeaders() inner.LastReceivedRequest.Content.Should().NotBeNull(); var body = await inner.LastReceivedRequest.Content!.ReadAsStringAsync(); body.Should().Be("{\"key\":\"value\"}"); + inner.LastReceivedRequest.Content.Headers.ContentEncoding.Should().Contain("gzip"); + inner.LastReceivedRequest.Content.Headers.ContentLanguage.Should().Contain("en-US"); + inner.LastReceivedRequest.Content.Headers.GetValues("X-Content-Signature").Should().Contain("sig-123"); inner.LastReceivedRequest.Headers.Contains("X-Custom").Should().BeTrue(); } [Fact] - public async Task SendAsync_307CrossHostRedirect_StripsAuthorizationHeader() + public async Task SendAsync_307CrossHostRedirect_StripsCredentialHeaders() { var registry = CreateRegistry("origin.example.com", "other.example.com"); var inner = new SingleRedirectHandler( @@ -227,13 +233,25 @@ public async Task SendAsync_307CrossHostRedirect_StripsAuthorizationHeader() var request = new HttpRequestMessage(HttpMethod.Post, "https://origin.example.com/api"); request.Content = new StringContent("data"); request.Headers.Add("Authorization", "Bearer secret-token"); + request.Headers.Add("Proxy-Authorization", "Basic proxy-secret"); + request.Headers.Add("Cookie", "session=secret"); + request.Headers.Add("x-goog-api-key", "gemini-secret"); + request.Headers.Add("X-Api-Key", "api-secret"); + request.Headers.Add("X-Provider-Token", "provider-token"); request.Headers.Add("X-Safe", "kept"); + request.Headers.Accept.ParseAdd("application/json"); var response = await invoker.SendAsync(request, CancellationToken.None); response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); inner.LastReceivedRequest!.Headers.Contains("Authorization").Should().BeFalse(); + inner.LastReceivedRequest.Headers.Contains("Proxy-Authorization").Should().BeFalse(); + inner.LastReceivedRequest.Headers.Contains("Cookie").Should().BeFalse(); + inner.LastReceivedRequest.Headers.Contains("x-goog-api-key").Should().BeFalse(); + inner.LastReceivedRequest.Headers.Contains("X-Api-Key").Should().BeFalse(); + inner.LastReceivedRequest.Headers.Contains("X-Provider-Token").Should().BeFalse(); inner.LastReceivedRequest.Headers.Contains("X-Safe").Should().BeTrue(); + inner.LastReceivedRequest.Headers.Accept.Should().Contain(h => h.MediaType == "application/json"); } [Fact] @@ -259,6 +277,168 @@ public async Task SendAsync_307SameHostRedirect_PreservesAuthorizationHeader() inner.LastReceivedRequest!.Headers.Contains("Authorization").Should().BeTrue(); } + [Fact] + public async Task SendAsync_307SameHostDifferentScheme_StripsAuthorizationHeader() + { + var registry = CreateRegistry("same.example.com"); + var inner = new SingleRedirectHandler( + "http://same.example.com/other-path", + System.Net.HttpStatusCode.TemporaryRedirect); + var handler = new EgressEnvelopeHandler(registry) + { + InnerHandler = inner + }; + using var invoker = new HttpMessageInvoker(handler); + + var request = new HttpRequestMessage(HttpMethod.Post, "https://same.example.com/api"); + request.Content = new StringContent("data"); + request.Headers.Add("Authorization", "Bearer strip-this"); + + var response = await invoker.SendAsync(request, CancellationToken.None); + + response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); + inner.LastReceivedRequest!.Headers.Contains("Authorization").Should().BeFalse(); + } + + [Fact] + public async Task SendAsync_307SameHostDifferentPort_StripsAuthorizationHeader() + { + var registry = CreateRegistry("same.example.com"); + var inner = new SingleRedirectHandler( + "https://same.example.com:8443/other-path", + System.Net.HttpStatusCode.TemporaryRedirect); + var handler = new EgressEnvelopeHandler(registry) + { + InnerHandler = inner + }; + using var invoker = new HttpMessageInvoker(handler); + + var request = new HttpRequestMessage(HttpMethod.Post, "https://same.example.com/api"); + request.Content = new StringContent("data"); + request.Headers.Add("Authorization", "Bearer strip-this"); + + var response = await invoker.SendAsync(request, CancellationToken.None); + + response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); + inner.LastReceivedRequest!.Headers.Contains("Authorization").Should().BeFalse(); + } + + [Fact] + public async Task SendAsync_302Then307Redirect_PreservesCurrentGetMethod() + { + var registry = CreateRegistry("origin.example.com", "origin.example.com"); + var inner = new SequenceRedirectHandler( + (System.Net.HttpStatusCode.Found, "https://origin.example.com/step-two"), + (System.Net.HttpStatusCode.TemporaryRedirect, "https://origin.example.com/final")); + var handler = new EgressEnvelopeHandler(registry) + { + InnerHandler = inner + }; + using var invoker = new HttpMessageInvoker(handler); + + var request = new HttpRequestMessage(HttpMethod.Post, "https://origin.example.com/start") + { + Content = new StringContent("payload") + }; + + var response = await invoker.SendAsync(request, CancellationToken.None); + + response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); + inner.ReceivedMethods.Should().Equal(HttpMethod.Post, HttpMethod.Get, HttpMethod.Get); + } + + [Fact] + public async Task SendAsync_RelativeRedirectAfterAbsoluteHop_ResolvesAgainstCurrentUri() + { + var registry = CreateRegistry("origin.example.com", "second.example.com"); + var inner = new SequenceRedirectHandler( + (System.Net.HttpStatusCode.Found, "https://second.example.com/step-two"), + (System.Net.HttpStatusCode.Found, "/final")); + var handler = new EgressEnvelopeHandler(registry) + { + InnerHandler = inner + }; + using var invoker = new HttpMessageInvoker(handler); + + var request = new HttpRequestMessage(HttpMethod.Get, "https://origin.example.com/start"); + + var response = await invoker.SendAsync(request, CancellationToken.None); + + response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); + inner.ReceivedUris.Should().Equal( + new Uri("https://origin.example.com/start"), + new Uri("https://second.example.com/step-two"), + new Uri("https://second.example.com/final")); + } + + [Fact] + public async Task SendAsync_UnknownLengthContentWithoutRedirect_DoesNotBufferContent() + { + var registry = CreateRegistry("origin.example.com"); + var handler = new EgressEnvelopeHandler(registry) + { + InnerHandler = new StubHandler() + }; + using var invoker = new HttpMessageInvoker(handler); + + var request = new HttpRequestMessage(HttpMethod.Post, "https://origin.example.com/api") + { + Content = new StreamContent(new ThrowOnReadStream()) + }; + + var response = await invoker.SendAsync(request, CancellationToken.None); + + response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); + } + + [Fact] + public async Task SendAsync_307RedirectWithUnknownLengthContent_FailsClosed() + { + var registry = CreateRegistry("origin.example.com"); + var inner = new SingleRedirectHandler( + "https://origin.example.com/continue", + System.Net.HttpStatusCode.TemporaryRedirect); + var handler = new EgressEnvelopeHandler(registry) + { + InnerHandler = inner + }; + using var invoker = new HttpMessageInvoker(handler); + + var request = new HttpRequestMessage(HttpMethod.Post, "https://origin.example.com/api") + { + Content = new StreamContent(new ThrowOnReadStream()) + }; + + var act = async () => await invoker.SendAsync(request, CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*Cannot replay request content*"); + } + + [Fact] + public async Task SendAsync_307RedirectWithOversizedContent_FailsClosedWithoutBuffering() + { + var registry = CreateRegistry("origin.example.com"); + var inner = new SingleRedirectHandler( + "https://origin.example.com/continue", + System.Net.HttpStatusCode.TemporaryRedirect); + var handler = new EgressEnvelopeHandler(registry) + { + InnerHandler = inner + }; + using var invoker = new HttpMessageInvoker(handler); + + var request = new HttpRequestMessage(HttpMethod.Post, "https://origin.example.com/api") + { + Content = new ByteArrayContent(new byte[EgressEnvelopeHandler.MaxRedirectReplayContentBytes + 1]) + }; + + var act = async () => await invoker.SendAsync(request, CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*Cannot replay request content*"); + } + // --- Test Helpers --- private sealed class StubHandler : HttpMessageHandler @@ -320,4 +500,61 @@ protected override Task SendAsync( return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)); } } + + private sealed class SequenceRedirectHandler : HttpMessageHandler + { + private readonly Queue<(System.Net.HttpStatusCode StatusCode, string Location)> _redirects; + + public List ReceivedMethods { get; } = new(); + public List ReceivedUris { get; } = new(); + + public SequenceRedirectHandler(params (System.Net.HttpStatusCode StatusCode, string Location)[] redirects) + { + _redirects = new Queue<(System.Net.HttpStatusCode StatusCode, string Location)>(redirects); + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + ReceivedMethods.Add(request.Method); + ReceivedUris.Add(request.RequestUri); + if (_redirects.TryDequeue(out var redirect)) + { + var response = new HttpResponseMessage(redirect.StatusCode); + response.Headers.Location = new Uri(redirect.Location, UriKind.RelativeOrAbsolute); + return Task.FromResult(response); + } + + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)); + } + } + + private sealed class ThrowOnReadStream : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + => throw new InvalidOperationException("Stream should not be read by the egress handler."); + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + } } From fbc336b713028663495d61cce940d052e856a92c Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 10 May 2026 18:55:43 +0100 Subject: [PATCH 3/3] Dispose buffered redirect content source --- .../Services/EgressEnvelopeHandler.cs | 2 + .../Services/EgressEnvelopeHandlerTests.cs | 52 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/backend/src/Taskdeck.Application/Services/EgressEnvelopeHandler.cs b/backend/src/Taskdeck.Application/Services/EgressEnvelopeHandler.cs index fc46b56c6..b5907b0c6 100644 --- a/backend/src/Taskdeck.Application/Services/EgressEnvelopeHandler.cs +++ b/backend/src/Taskdeck.Application/Services/EgressEnvelopeHandler.cs @@ -189,8 +189,10 @@ private static bool IsRedirect(HttpResponseMessage response) $"Request content exceeds the {MaxRedirectReplayContentBytes} byte redirect replay limit."); } + var originalContent = request.Content; var replayContent = new ReplayableContent(content, headers); request.Content = CreateReplayContent(replayContent); + originalContent.Dispose(); return replayContent; } diff --git a/backend/tests/Taskdeck.Application.Tests/Services/EgressEnvelopeHandlerTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/EgressEnvelopeHandlerTests.cs index 6b1a9c57c..50993c5ab 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/EgressEnvelopeHandlerTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/EgressEnvelopeHandlerTests.cs @@ -1,3 +1,4 @@ +using System.Net; using FluentAssertions; using Taskdeck.Application.Services; using Taskdeck.Domain.Agents; @@ -391,6 +392,29 @@ public async Task SendAsync_UnknownLengthContentWithoutRedirect_DoesNotBufferCon response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); } + [Fact] + public async Task SendAsync_BufferedReplayContent_DisposesOriginalContent() + { + var registry = CreateRegistry("origin.example.com"); + var handler = new EgressEnvelopeHandler(registry) + { + InnerHandler = new StubHandler() + }; + using var invoker = new HttpMessageInvoker(handler); + var originalContent = new TrackingContent("payload"); + + var request = new HttpRequestMessage(HttpMethod.Post, "https://origin.example.com/api") + { + Content = originalContent + }; + + var response = await invoker.SendAsync(request, CancellationToken.None); + + response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); + originalContent.Disposed.Should().BeTrue(); + request.Content.Should().NotBeSameAs(originalContent); + } + [Fact] public async Task SendAsync_307RedirectWithUnknownLengthContent_FailsClosed() { @@ -557,4 +581,32 @@ public override void SetLength(long value) public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); } + + private sealed class TrackingContent : HttpContent + { + private readonly byte[] _content; + + public TrackingContent(string content) + { + _content = System.Text.Encoding.UTF8.GetBytes(content); + Headers.ContentLength = _content.Length; + } + + public bool Disposed { get; private set; } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + => stream.WriteAsync(_content, 0, _content.Length); + + protected override bool TryComputeLength(out long length) + { + length = _content.Length; + return true; + } + + protected override void Dispose(bool disposing) + { + Disposed = true; + base.Dispose(disposing); + } + } }