diff --git a/backend/src/Taskdeck.Application/Services/EgressEnvelopeHandler.cs b/backend/src/Taskdeck.Application/Services/EgressEnvelopeHandler.cs index 8e9ec706c..b5907b0c6 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,7 +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. - 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; @@ -52,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; @@ -67,31 +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 headers and body + // 307/308 require preserving the original body and safe headers if (statusCode is 307 or 308) { - redirectRequest.Content = request.Content; - foreach (var header in request.Headers) + if (previousRequest.Content is not null) + { + 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 isCrossOrigin = !IsSameOrigin(previousRequest.RequestUri, resolvedRedirectUri); + foreach (var header in previousRequest.Headers) { + if (string.Equals(header.Key, "Host", StringComparison.OrdinalIgnoreCase)) + continue; + 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); } @@ -139,6 +165,69 @@ 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 originalContent = request.Content; + var replayContent = new ReplayableContent(content, headers); + request.Content = CreateReplayContent(replayContent); + originalContent.Dispose(); + 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 14b19e0ce..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; @@ -184,7 +185,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( @@ -198,6 +199,9 @@ public async Task SendAsync_307Redirect_PreservesHeadersAndContent() 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); @@ -206,9 +210,259 @@ 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.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_StripsCredentialHeaders() + { + 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("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] + 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(); + } + + [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_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() + { + 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 @@ -270,4 +524,89 @@ 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(); + } + + 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); + } + } }