Skip to content

Commit be26c6e

Browse files
rolfbjarneCopilot
andauthored
[Foundation] Remove Content-Encoding and Content-Length headers for auto-decompressed responses in NSUrlSessionHandler. Fixes #23958. (#24924)
NSURLSession automatically decompresses content for supported encodings (gzip, deflate, br, zstd, etc.) but leaves the original Content-Encoding and Content-Length headers in the response. This causes a mismatch where Content-Length refers to the compressed size while the body is decompressed. All other HTTP handlers (SocketsHttpHandler, WinHttpHandler, Android's handler) remove these headers after automatic decompression. This change makes NSUrlSessionHandler consistent with them. Fixes #23958 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 39ca90e commit be26c6e

2 files changed

Lines changed: 131 additions & 0 deletions

File tree

src/Foundation/NSUrlSessionHandler.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ public static string GetHeaderValue (this NSHttpCookie cookie)
112112
public partial class NSUrlSessionHandler : HttpMessageHandler {
113113
private const string SetCookie = "Set-Cookie";
114114
private const string Cookie = "Cookie";
115+
private const string ContentEncodingHeaderName = "Content-Encoding";
116+
private const string ContentLengthHeaderName = "Content-Length";
115117
private CookieContainer? cookieContainer;
116118
readonly Dictionary<string, string> headerSeparators = new Dictionary<string, string> {
117119
["User-Agent"] = " ",
@@ -869,6 +871,24 @@ public bool UseProxy {
869871
}
870872
}
871873

874+
static bool HasCompressedEncoding (string headerValue)
875+
{
876+
foreach (var encoding in headerValue.Split (',')) {
877+
if (IsCompressedEncoding (encoding.Trim ()))
878+
return true;
879+
}
880+
return false;
881+
}
882+
883+
static bool IsCompressedEncoding (string encoding)
884+
{
885+
return string.Equals (encoding, "gzip", StringComparison.OrdinalIgnoreCase)
886+
|| string.Equals (encoding, "deflate", StringComparison.OrdinalIgnoreCase)
887+
|| string.Equals (encoding, "br", StringComparison.OrdinalIgnoreCase)
888+
|| string.Equals (encoding, "compress", StringComparison.OrdinalIgnoreCase)
889+
|| string.Equals (encoding, "zstd", StringComparison.OrdinalIgnoreCase);
890+
}
891+
872892
partial class NSUrlSessionHandlerDelegate : NSUrlSessionDataDelegate {
873893
readonly NSUrlSessionHandler sessionHandler;
874894

@@ -959,6 +979,17 @@ void DidReceiveResponseImpl (NSUrlSession session, NSUrlSessionDataTask dataTask
959979
if (wasRedirected)
960980
httpResponse.RequestMessage.RequestUri = absoluteUri;
961981

982+
// NSURLSession automatically decompresses content for all supported
983+
// encodings (gzip, deflate, br, zstd, etc.), and there's no way to
984+
// turn it off. After decompression, Content-Encoding and Content-Length
985+
// are stale (Content-Length refers to compressed size), so we need to
986+
// remove them to match the behavior of other HTTP handlers:
987+
// - SocketsHttpHandler: https://github.com/dotnet/runtime/blob/b2974279efd059efaa17f359ed4b266b1c705721/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/DecompressionHandler.cs#L122-L123
988+
// - AndroidMessageHandler: https://github.com/dotnet/android/pull/7785
989+
// Ref: https://github.com/dotnet/macios/issues/23958
990+
string? contentEncodingValue = null;
991+
string? contentLengthValue = null;
992+
962993
foreach (var v in urlResponse.AllHeaderFields) {
963994
var key = v.Key?.ToString ();
964995
var value = v.Value?.ToString ();
@@ -968,10 +999,31 @@ void DidReceiveResponseImpl (NSUrlSession session, NSUrlSessionDataTask dataTask
968999
// NSUrlSession tries to be smart with cookies, we will not use the raw value but the ones provided by the cookie storage
9691000
if (key == SetCookie) continue;
9701001

1002+
if (string.Equals (key, ContentEncodingHeaderName, StringComparison.OrdinalIgnoreCase)) {
1003+
contentEncodingValue = value;
1004+
continue;
1005+
}
1006+
if (string.Equals (key, ContentLengthHeaderName, StringComparison.OrdinalIgnoreCase)) {
1007+
contentLengthValue = value;
1008+
continue;
1009+
}
1010+
9711011
httpResponse.Headers.TryAddWithoutValidation (key, value);
9721012
httpResponse.Content.Headers.TryAddWithoutValidation (key, value);
9731013
}
9741014

1015+
var contentWasDecompressed = contentEncodingValue is not null && HasCompressedEncoding (contentEncodingValue);
1016+
if (!contentWasDecompressed) {
1017+
if (contentEncodingValue is not null) {
1018+
httpResponse.Headers.TryAddWithoutValidation (ContentEncodingHeaderName, contentEncodingValue);
1019+
httpResponse.Content.Headers.TryAddWithoutValidation (ContentEncodingHeaderName, contentEncodingValue);
1020+
}
1021+
if (contentLengthValue is not null) {
1022+
httpResponse.Headers.TryAddWithoutValidation (ContentLengthHeaderName, contentLengthValue);
1023+
httpResponse.Content.Headers.TryAddWithoutValidation (ContentLengthHeaderName, contentLengthValue);
1024+
}
1025+
}
1026+
9751027
// it might be confusing that we are not using the managed CookieStore here, this is ONLY for those cookies that have been retrieved from
9761028
// the server via a Set-Cookie header, the managed container does not know a thing about this and apple is storing them in the native
9771029
// cookie container. Once we have the cookies from the response, we need to update the managed cookie container

tests/monotouch-test/System.Net.Http/NSUrlSessionHandlerTest.cs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,85 @@ namespace MonoTests.System.Net.Http {
1414
[Preserve (AllMembers = true)]
1515
public class NSUrlSessionHandlerTest {
1616

17+
// https://github.com/dotnet/macios/issues/23958
18+
[Test]
19+
public void DecompressedResponseDoesNotHaveContentEncodingOrContentLength ()
20+
{
21+
bool noContentEncoding = false;
22+
bool noContentLength = false;
23+
string body = "";
24+
25+
var done = TestRuntime.TryRunAsync (TimeSpan.FromSeconds (30), async () => {
26+
using var handler = new NSUrlSessionHandler ();
27+
using var client = new HttpClient (handler);
28+
// Explicitly request gzip to ensure the server compresses the response.
29+
using var request = new HttpRequestMessage (HttpMethod.Get, $"{NetworkResources.Httpbin.Url}/gzip");
30+
request.Headers.TryAddWithoutValidation ("Accept-Encoding", "gzip");
31+
// Use ResponseHeadersRead so that the response content is not buffered,
32+
// which would cause HttpContent to compute Content-Length from the buffer.
33+
var response = await client.SendAsync (request, HttpCompletionOption.ResponseHeadersRead);
34+
35+
if (!response.IsSuccessStatusCode) {
36+
Assert.Inconclusive ($"Request failed with status {response.StatusCode}");
37+
return;
38+
}
39+
40+
noContentEncoding = response.Content.Headers.ContentEncoding.Count == 0;
41+
noContentLength = response.Content.Headers.ContentLength is null;
42+
body = await response.Content.ReadAsStringAsync ();
43+
}, out var ex);
44+
45+
if (!done) {
46+
TestRuntime.IgnoreInCI ("Transient network failure - ignore in CI");
47+
Assert.Inconclusive ("Request timed out.");
48+
}
49+
TestRuntime.IgnoreInCIIfBadNetwork (ex);
50+
Assert.IsNull (ex, $"Exception: {ex}");
51+
Assert.IsTrue (noContentEncoding, "Content-Encoding header should be removed for decompressed content");
52+
Assert.IsTrue (noContentLength, "Content-Length header should be removed for decompressed content");
53+
Assert.IsTrue (body.Contains ("\"gzipped\"", StringComparison.OrdinalIgnoreCase), "Response body should contain decompressed gzip data");
54+
}
55+
56+
// https://github.com/dotnet/macios/issues/23958
57+
[Test]
58+
public void NonCompressedResponseHasContentLength ()
59+
{
60+
bool noContentEncoding = false;
61+
long? contentLength = null;
62+
string body = "";
63+
64+
var done = TestRuntime.TryRunAsync (TimeSpan.FromSeconds (30), async () => {
65+
using var handler = new NSUrlSessionHandler ();
66+
using var client = new HttpClient (handler);
67+
// Request identity encoding to ensure no compression is applied.
68+
using var request = new HttpRequestMessage (HttpMethod.Get, $"{NetworkResources.Httpbin.Url}/html");
69+
request.Headers.TryAddWithoutValidation ("Accept-Encoding", "identity");
70+
// Use ResponseHeadersRead so that the response content is not buffered,
71+
// which would cause HttpContent to compute Content-Length from the buffer.
72+
var response = await client.SendAsync (request, HttpCompletionOption.ResponseHeadersRead);
73+
74+
if (!response.IsSuccessStatusCode) {
75+
Assert.Inconclusive ($"Request failed with status {response.StatusCode}");
76+
return;
77+
}
78+
79+
noContentEncoding = response.Content.Headers.ContentEncoding.Count == 0;
80+
contentLength = response.Content.Headers.ContentLength;
81+
body = await response.Content.ReadAsStringAsync ();
82+
}, out var ex);
83+
84+
if (!done) {
85+
TestRuntime.IgnoreInCI ("Transient network failure - ignore in CI");
86+
Assert.Inconclusive ("Request timed out.");
87+
}
88+
TestRuntime.IgnoreInCIIfBadNetwork (ex);
89+
Assert.IsNull (ex, $"Exception: {ex}");
90+
Assert.IsTrue (noContentEncoding, "Content-Encoding should not be present for non-compressed content");
91+
Assert.IsNotNull (contentLength, "Content-Length header should be present for non-compressed content");
92+
Assert.IsTrue (contentLength > 0, "Content-Length should be greater than zero");
93+
Assert.IsTrue (body.Length > 0, "Response body should not be empty");
94+
}
95+
1796
// https://github.com/dotnet/macios/issues/24376
1897
[Test]
1998
public void DisposeAndRecreateBackgroundSessionHandler ()

0 commit comments

Comments
 (0)