From 6262afff6aa891e6eed24d7500fb0ce1614230dd Mon Sep 17 00:00:00 2001 From: Bruno Gale Date: Thu, 1 Oct 2026 19:54:42 +0200 Subject: [PATCH] fix(storage): send upload headers per request --- .../Files/ResumableUploadContractTests.cs | 19 +++++++++++++++ .../Files/StorageFileApiContractTests.cs | 18 +++++++++++++++ .../Storage/Extensions/HttpClientProgress.cs | 23 ++++++++++++++----- 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/packages/Storage/Storage.Tests/Files/ResumableUploadContractTests.cs b/packages/Storage/Storage.Tests/Files/ResumableUploadContractTests.cs index 332a38ca..51c01fa1 100644 --- a/packages/Storage/Storage.Tests/Files/ResumableUploadContractTests.cs +++ b/packages/Storage/Storage.Tests/Files/ResumableUploadContractTests.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.IO; using System.Linq; +using System.Net.Http; using System.Threading.Tasks; using FluentAssertions; using FluentAssertions.Execution; @@ -90,6 +91,24 @@ public async Task UploadOrResume_ShouldCloseTheLocalFile() File.Delete(path); } + [TestMethod] + public async Task UploadOrResume_ShouldNotPutHeadersOnTheSharedHttpClient() + { + using var injectedClient = new HttpClient(); + injectedClient.DefaultRequestHeaders.Add("X-Injected", "true"); + var storage = new Client($"{this.server.Url}/storage/v1", new ClientOptions { HttpUploadClient = injectedClient }, + new Dictionary { { "Authorization", "Bearer test-key" } }); + await storage.From(Bucket).UploadOrResume(Payload, FileName, new FileOptions()); + using (new AssertionScope()) + { + injectedClient.DefaultRequestHeaders.Select(header => header.Key).Should().Equal(new[] { "X-Injected" }, + "an upload must not leave its headers on a client other uploads share (issue #469)"); + this.server.LogEntries.Select(entry => entry.RequestMessage!.Headers!.Keys).Should().OnlyContain( + keys => keys.Contains("Authorization") && keys.Contains("X-Injected"), + "every TUS request must carry both the upload's headers and the caller's own (issue #469)"); + } + } + private void StubTusEndpoints() { var length = Payload.Length.ToString(CultureInfo.InvariantCulture); diff --git a/packages/Storage/Storage.Tests/Files/StorageFileApiContractTests.cs b/packages/Storage/Storage.Tests/Files/StorageFileApiContractTests.cs index 55d7a5b7..ba5345e3 100644 --- a/packages/Storage/Storage.Tests/Files/StorageFileApiContractTests.cs +++ b/packages/Storage/Storage.Tests/Files/StorageFileApiContractTests.cs @@ -2,6 +2,7 @@ using System.IO; using System.Linq; using System.Collections.Generic; +using System.Net.Http; using System.Text; using System.Threading.Tasks; using FluentAssertions; @@ -278,6 +279,23 @@ public async Task Upload_ShouldReportProgress_GivenAProgressHandler() onProgress.Received().Invoke(Arg.Any(), Arg.Any()); } + [TestMethod] + public async Task Upload_ShouldNotPutHeadersOnTheSharedHttpClient() + { + using var injectedClient = new HttpClient(); + injectedClient.DefaultRequestHeaders.Add("X-Injected", "true"); + var storage = new Client($"{this.server.Url}/storage/v1", new ClientOptions { HttpUploadClient = injectedClient }, + new Dictionary { { "Authorization", "Bearer test-key" } }); + this.Respond($"/storage/v1/object/{Bucket}/a.bin", "POST", 200, "{\"Key\":\"x\"}"); + await storage.From(Bucket).Upload(new byte[] { 0x1 }, "a.bin"); + using (new AssertionScope()) + { + injectedClient.DefaultRequestHeaders.Select(header => header.Key).Should().Equal(new[] { "X-Injected" }, + "an upload must not leave its headers on a client other uploads share (issue #469)"); + this.SingleRequest().Headers.Should().ContainKey("X-Injected", "the caller's own default headers must still be sent (issue #469)"); + } + } + [TestMethod] public async Task Download_ShouldGetBytesFromTheObjectPath() { diff --git a/packages/Storage/Storage/Extensions/HttpClientProgress.cs b/packages/Storage/Storage/Extensions/HttpClientProgress.cs index 13d55f0e..f411980a 100644 --- a/packages/Storage/Storage/Extensions/HttpClientProgress.cs +++ b/packages/Storage/Storage/Extensions/HttpClientProgress.cs @@ -195,17 +195,16 @@ public static async Task UploadAsync( ) { var content = new ProgressableStreamContent(stream, 4096, progress); + var request = new HttpRequestMessage(HttpMethod.Post, uri) { Content = content }; if (headers != null) { - client.DefaultRequestHeaders.Clear(); - foreach (var header in headers) { if (header.Key.Contains("content")) content.Headers.Add(header.Key, header.Value); else - client.DefaultRequestHeaders.Add(header.Key, header.Value); + request.Headers.Add(header.Key, header.Value); } } @@ -217,7 +216,7 @@ public static async Task UploadAsync( try { - var response = await client.PostAsync(uri, content, cancellationToken); + var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); statusCode = (int) response.StatusCode; activity.SetHttpResponseTags(statusCode.Value); @@ -315,12 +314,14 @@ private static async Task ResumableUploadAsync( fileStream.Seek(0, SeekOrigin.Begin); } + // Copied and validated up front, not in AddHeaders: BirdMessenger swallows exceptions from that + // callback, and the caller's dictionary can change during a long upload. + var uploadHeaders = new HttpRequestMessage().Headers; if (headers != null) { - client.DefaultRequestHeaders.Clear(); foreach (var header in headers) { - client.DefaultRequestHeaders.Add(header.Key, header.Value); + uploadHeaders.Add(header.Key, header.Value); } } @@ -347,6 +348,7 @@ private static async Task ResumableUploadAsync( Endpoint = uri, Metadata = metadata, UploadLength = fileStream.Length, + OnPreSendRequestAsync = AddHeaders, }; try @@ -384,6 +386,7 @@ private static async Task ResumableUploadAsync( return Task.CompletedTask; }, OnFailedAsync = _ => Task.CompletedTask, + OnPreSendRequestAsync = AddHeaders, }; var responsePatch = await client.TusPatchAsync(patchOption, cancellationToken); @@ -406,6 +409,14 @@ private static async Task ResumableUploadAsync( { StorageInstrumentation.RecordTransfer(StorageInstrumentation.DirectionUpload, HttpMethod.Post, uri, bytes, statusCode, errorType, startTimestamp); } + + Task AddHeaders(PreSendRequestEvent preSend) + { + foreach (var header in uploadHeaders) + preSend.HttpRequestMsg.Headers.Add(header.Key, header.Value); + + return Task.CompletedTask; + } } private static Task ReportProgressAsync(