From 5cfa8e7b0bd390a43d6fd3151d910a77bf605f2d Mon Sep 17 00:00:00 2001 From: antonfirsov Date: Thu, 3 Jul 2025 22:54:27 +0200 Subject: [PATCH 1/3] replace dangerous response header characters --- .../Net/Http/Headers/HeaderDescriptor.cs | 32 ++++++++++++++- .../SocketsHttpHandler/HttpConnectionBase.cs | 2 +- .../HttpClientHandlerTest.Headers.cs | 40 +++++++++++++++++-- 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/Headers/HeaderDescriptor.cs b/src/libraries/System.Net.Http/src/System/Net/Http/Headers/HeaderDescriptor.cs index a4d44b2a307145..162bc66c9a5316 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/Headers/HeaderDescriptor.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/Headers/HeaderDescriptor.cs @@ -15,6 +15,9 @@ namespace System.Net.Http.Headers // Use HeaderDescriptor.TryGet to resolve an arbitrary header name to a HeaderDescriptor. internal readonly struct HeaderDescriptor : IEquatable { + private static readonly SearchValues s_dangerousCharacters = SearchValues.Create('\0', '\r', '\n'); + private static readonly SearchValues s_dangerousCharacterBytes = SearchValues.Create((byte)'\0', (byte)'\r', (byte)'\n'); + /// /// Either a or . /// @@ -129,7 +132,21 @@ public HeaderDescriptor AsCustomHeader() return new HeaderDescriptor(Name, customHeader: true); } - public string GetHeaderValue(ReadOnlySpan headerValue, Encoding? valueEncoding) + private readonly ref struct CreateHeaderStringState + { + public readonly ReadOnlySpan Bytes; + public readonly Encoding Encoding; + public readonly bool ReplaceDangerousCharacters; + + public CreateHeaderStringState(ReadOnlySpan bytes, Encoding encoding, bool replaceDangerousCharacters) + { + Bytes = bytes; + Encoding = encoding; + ReplaceDangerousCharacters = replaceDangerousCharacters; + } + } + + public string GetHeaderValue(ReadOnlySpan headerValue, Encoding? valueEncoding, bool replaceDangerousCharacters = false) { if (headerValue.Length == 0) { @@ -169,7 +186,18 @@ public string GetHeaderValue(ReadOnlySpan headerValue, Encoding? valueEnco } } - return (valueEncoding ?? HttpRuleParser.DefaultHttpEncoding).GetString(headerValue); + Encoding encoding = valueEncoding ?? HttpRuleParser.DefaultHttpEncoding; + replaceDangerousCharacters = replaceDangerousCharacters && headerValue.IndexOfAny(s_dangerousCharacterBytes) >= 0; + int length = encoding.GetCharCount(headerValue); + return string.Create(length, new CreateHeaderStringState(headerValue, encoding, replaceDangerousCharacters), static (chars, s) => + { + int doubleCheck = s.Encoding.GetChars(s.Bytes, chars); + Debug.Assert(chars.Length == doubleCheck); + if (s.ReplaceDangerousCharacters) + { + chars.ReplaceAny(s_dangerousCharacters, ' '); + } + }); } internal static string? GetKnownContentType(ReadOnlySpan contentTypeValue) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs index 786aff42b94a76..5b5bc840e25b77 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs @@ -131,7 +131,7 @@ public string GetResponseHeaderValueWithCaching(HeaderDescriptor descriptor, Rea return descriptor.Equals(KnownHeaders.Date) ? GetOrAddCachedValue(ref _lastDateHeaderValue, descriptor, value, valueEncoding) : descriptor.Equals(KnownHeaders.Server) ? GetOrAddCachedValue(ref _lastServerHeaderValue, descriptor, value, valueEncoding) : - descriptor.GetHeaderValue(value, valueEncoding); + descriptor.GetHeaderValue(value, valueEncoding, replaceDangerousCharacters: true); static string GetOrAddCachedValue([NotNull] ref string? cache, HeaderDescriptor descriptor, ReadOnlySpan value, Encoding? encoding) { diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Headers.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Headers.cs index 48d72d48880133..308cda02ec9361 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Headers.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Headers.cs @@ -5,11 +5,10 @@ using System.IO; using System.Linq; using System.Net.Http.Headers; -using System.Net.Quic; using System.Net.Test.Common; using System.Text; using System.Threading.Tasks; - +using Microsoft.DotNet.XUnitExtensions; using Xunit; using Xunit.Abstractions; @@ -160,7 +159,8 @@ await LoopbackServerFactory.CreateClientAndServerAsync(async uri => // Client should abort at some point so this is going to throw. HttpRequestData requestData = await server.HandleRequestAsync(HttpStatusCode.OK).ConfigureAwait(false); } - catch (Exception) { }; + catch (Exception) { } + ; }); } @@ -595,5 +595,39 @@ await connection.SendResponseAsync(HttpStatusCode.OK, }); }); } + + [ConditionalTheory] + [InlineData(false, "test\nxwow\nmore\n")] + [InlineData(false, "test\rwow\rmore\r")] + [InlineData(false, "test\r\nwow\r\nmore\r\n")] + [InlineData(true, "one\0two\0three\0")] + public async Task SendAsync_InvalidCharactersInResponseHeader_ReplacedWithSpaces(bool testHttp11, string value) + { + if (!testHttp11 && UseVersion == HttpVersion.Version11) + { + throw new SkipTestException("This case is not valid for HTTP 1.1"); + } + + string expectedValue = value.Replace('\r', ' ').Replace('\n', ' ').Replace('\0', ' '); + await LoopbackServerFactory.CreateClientAndServerAsync( + async uri => + { + using HttpClient client = CreateHttpClient(); + + using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, uri) + { + Version = UseVersion, + VersionPolicy = HttpVersionPolicy.RequestVersionExact + }; + + using HttpResponseMessage response = await client.SendAsync(request); + Assert.Equal(expectedValue, response.Headers.GetValues("test").Single()); + }, + async server => + { + List headers = [new HttpHeaderData("test", value)]; + HttpRequestData requestData = await server.AcceptConnectionSendResponseAndCloseAsync(additionalHeaders: headers); + }); + } } } From dc3f254a3778776eb7ed0c6866ebe5c497cc750a Mon Sep 17 00:00:00 2001 From: antonfirsov Date: Mon, 7 Jul 2025 21:54:31 +0200 Subject: [PATCH 2/3] rework the solution --- .../Net/Http/Headers/HeaderDescriptor.cs | 37 +++------ .../SocketsHttpHandler/HttpConnectionBase.cs | 2 +- .../HttpClientHandlerTest.Headers.cs | 3 +- .../UnitTests/Headers/HeaderEncodingTest.cs | 80 +++++++++++++++---- 4 files changed, 76 insertions(+), 46 deletions(-) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/Headers/HeaderDescriptor.cs b/src/libraries/System.Net.Http/src/System/Net/Http/Headers/HeaderDescriptor.cs index 162bc66c9a5316..b7af4667d59c46 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/Headers/HeaderDescriptor.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/Headers/HeaderDescriptor.cs @@ -15,7 +15,6 @@ namespace System.Net.Http.Headers // Use HeaderDescriptor.TryGet to resolve an arbitrary header name to a HeaderDescriptor. internal readonly struct HeaderDescriptor : IEquatable { - private static readonly SearchValues s_dangerousCharacters = SearchValues.Create('\0', '\r', '\n'); private static readonly SearchValues s_dangerousCharacterBytes = SearchValues.Create((byte)'\0', (byte)'\r', (byte)'\n'); /// @@ -132,21 +131,7 @@ public HeaderDescriptor AsCustomHeader() return new HeaderDescriptor(Name, customHeader: true); } - private readonly ref struct CreateHeaderStringState - { - public readonly ReadOnlySpan Bytes; - public readonly Encoding Encoding; - public readonly bool ReplaceDangerousCharacters; - - public CreateHeaderStringState(ReadOnlySpan bytes, Encoding encoding, bool replaceDangerousCharacters) - { - Bytes = bytes; - Encoding = encoding; - ReplaceDangerousCharacters = replaceDangerousCharacters; - } - } - - public string GetHeaderValue(ReadOnlySpan headerValue, Encoding? valueEncoding, bool replaceDangerousCharacters = false) + public string GetHeaderValue(ReadOnlySpan headerValue, Encoding? valueEncoding) { if (headerValue.Length == 0) { @@ -186,18 +171,16 @@ public string GetHeaderValue(ReadOnlySpan headerValue, Encoding? valueEnco } } - Encoding encoding = valueEncoding ?? HttpRuleParser.DefaultHttpEncoding; - replaceDangerousCharacters = replaceDangerousCharacters && headerValue.IndexOfAny(s_dangerousCharacterBytes) >= 0; - int length = encoding.GetCharCount(headerValue); - return string.Create(length, new CreateHeaderStringState(headerValue, encoding, replaceDangerousCharacters), static (chars, s) => + string value = (valueEncoding ?? HttpRuleParser.DefaultHttpEncoding).GetString(headerValue); + if (headerValue.ContainsAny(s_dangerousCharacterBytes)) { - int doubleCheck = s.Encoding.GetChars(s.Bytes, chars); - Debug.Assert(chars.Length == doubleCheck); - if (s.ReplaceDangerousCharacters) - { - chars.ReplaceAny(s_dangerousCharacters, ' '); - } - }); + // Depending on the encoding, 'value' may contain a dangerous character. + // We are replacing them with SP to conform with https://www.rfc-editor.org/rfc/rfc9110.html#section-5.5-5. + // This is a low-occurrence corner case, so we don't care about the cost of Replace() and the extra allocations. + value = value.Replace('\0', ' ').Replace('\r', ' ').Replace('\n', ' '); + } + + return value; } internal static string? GetKnownContentType(ReadOnlySpan contentTypeValue) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs index 5b5bc840e25b77..786aff42b94a76 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionBase.cs @@ -131,7 +131,7 @@ public string GetResponseHeaderValueWithCaching(HeaderDescriptor descriptor, Rea return descriptor.Equals(KnownHeaders.Date) ? GetOrAddCachedValue(ref _lastDateHeaderValue, descriptor, value, valueEncoding) : descriptor.Equals(KnownHeaders.Server) ? GetOrAddCachedValue(ref _lastServerHeaderValue, descriptor, value, valueEncoding) : - descriptor.GetHeaderValue(value, valueEncoding, replaceDangerousCharacters: true); + descriptor.GetHeaderValue(value, valueEncoding); static string GetOrAddCachedValue([NotNull] ref string? cache, HeaderDescriptor descriptor, ReadOnlySpan value, Encoding? encoding) { diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Headers.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Headers.cs index 308cda02ec9361..e81430a6492543 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Headers.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Headers.cs @@ -598,8 +598,7 @@ await connection.SendResponseAsync(HttpStatusCode.OK, [ConditionalTheory] [InlineData(false, "test\nxwow\nmore\n")] - [InlineData(false, "test\rwow\rmore\r")] - [InlineData(false, "test\r\nwow\r\nmore\r\n")] + [InlineData(false, "test\rwow\rmore\r\n")] [InlineData(true, "one\0two\0three\0")] public async Task SendAsync_InvalidCharactersInResponseHeader_ReplacedWithSpaces(bool testHttp11, string value) { diff --git a/src/libraries/System.Net.Http/tests/UnitTests/Headers/HeaderEncodingTest.cs b/src/libraries/System.Net.Http/tests/UnitTests/Headers/HeaderEncodingTest.cs index d7f41e0deeee61..d4ff2108475d4a 100644 --- a/src/libraries/System.Net.Http/tests/UnitTests/Headers/HeaderEncodingTest.cs +++ b/src/libraries/System.Net.Http/tests/UnitTests/Headers/HeaderEncodingTest.cs @@ -1,37 +1,85 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Linq; using System.Net.Http.Headers; using System.Text; +using Microsoft.DotNet.XUnitExtensions; using Xunit; namespace System.Net.Http.Tests { public class HeaderEncodingTest { - [Theory] - [InlineData("")] - [InlineData("foo")] - [InlineData("\uD83D\uDE03")] - [InlineData("\0")] - [InlineData("\x01")] - [InlineData("\xFF")] - [InlineData("\uFFFF")] - [InlineData("\uFFFD")] - [InlineData("\uD83D\uDE48\uD83D\uDE49\uD83D\uDE4A")] - public void RoundTripsUtf8(string input) + public static readonly TheoryData RoundTrips_Data = new TheoryData { - byte[] encoded = Encoding.UTF8.GetBytes(input); + { "", null }, + { "foo", null }, + { "\uD83D\uDE03", null }, + { "\x01", null }, + { "\xFF", null }, + { "\uFFFF", null }, + { "\uFFFD", null }, + { "\uD83D\uDE48\uD83D\uDE49\uD83D\uDE4A", null }, + { "\0", null }, + { "abc\056", null }, + { "abc\rq", null }, + { "abc\r\n", null }, + { "abc\rfoo", null }, + + { "", "UTF-8" }, + { "foo", "UTF-8" }, + { "\uD83D\uDE03", "UTF-8" }, + { "\x01", "UTF-8" }, + { "\xFF", "UTF-8" }, + { "\uFFFF", "UTF-8" }, + { "\uFFFD", "UTF-8" }, + { "\uD83D\uDE48\uD83D\uDE49\uD83D\uDE4A", "UTF-8" }, + { "\0", "UTF-8" }, + { "abc\056", "UTF-8" }, + { "abc\rq", "UTF-8" }, + { "abc\r\n", "UTF-8" }, + { "abc\rfoo", "UTF-8" }, + + // Fixed, multi byte encodings are discouraged, but we want them to function at HeaderDescriptor level. + { "", "UTF-16" }, + { "foo", "UTF-16" }, + { "\uD83D\uDE03", "UTF-16" }, + { "\x01", "UTF-16" }, + { "\xFF", "UTF-16" }, + { "\uFFFF", "UTF-16" }, + { "\uFFFD", "UTF-16" }, + { "\uD83D\uDE48\uD83D\uDE49\uD83D\uDE4A", "UTF-16" }, + { "\0", "UTF-16" }, + { "abc\056", "UTF-16" }, + { "abc\rq", "UTF-16" }, + { "abc\r\n", "UTF-16" }, + { "abc\rfoo", "UTF-16" }, + }; + + [ConditionalTheory] + [MemberData(nameof(RoundTrips_Data))] + public void GetHeaderValue_RoundTrips_ReplacesDangerousCharacters(string input, string? encodingName) + { + bool isUnicode = input.Any(c => c > 255); + if (isUnicode && encodingName == null) + { + throw new SkipTestException("The test case is invalid for the default encoding."); + } + + Encoding encoding = encodingName == null ? null : Encoding.GetEncoding(encodingName); + byte[] encoded = (encoding ?? Encoding.Latin1).GetBytes(input); + string expectedValue = input.Replace('\0', ' ').Replace('\r', ' ').Replace('\n', ' '); Assert.True(HeaderDescriptor.TryGet("custom-header", out HeaderDescriptor descriptor)); Assert.Null(descriptor.KnownHeader); - string roundtrip = descriptor.GetHeaderValue(encoded, Encoding.UTF8); - Assert.Equal(input, roundtrip); + string roundtrip = descriptor.GetHeaderValue(encoded, encoding); + Assert.Equal(expectedValue, roundtrip); Assert.True(HeaderDescriptor.TryGet("Cache-Control", out descriptor)); Assert.NotNull(descriptor.KnownHeader); - roundtrip = descriptor.GetHeaderValue(encoded, Encoding.UTF8); - Assert.Equal(input, roundtrip); + roundtrip = descriptor.GetHeaderValue(encoded, encoding); + Assert.Equal(expectedValue, roundtrip); } } } From b8020c9b8a2e5537b37ff5d266a4834a40454841 Mon Sep 17 00:00:00 2001 From: antonfirsov Date: Tue, 8 Jul 2025 00:22:30 +0200 Subject: [PATCH 3/3] cover trailers in tests --- .../System/Net/Http/GenericLoopbackServer.cs | 2 +- .../Net/Http/Http2LoopbackConnection.cs | 4 +- .../Net/Http/Http3LoopbackConnection.cs | 10 ++++- .../tests/System/Net/Http/LoopbackServer.cs | 16 +++++++- .../HttpClientHandlerTest.Headers.cs | 37 +++++++++++++++---- 5 files changed, 54 insertions(+), 15 deletions(-) diff --git a/src/libraries/Common/tests/System/Net/Http/GenericLoopbackServer.cs b/src/libraries/Common/tests/System/Net/Http/GenericLoopbackServer.cs index 93a9c6318b601e..f92e651ec0a675 100644 --- a/src/libraries/Common/tests/System/Net/Http/GenericLoopbackServer.cs +++ b/src/libraries/Common/tests/System/Net/Http/GenericLoopbackServer.cs @@ -148,7 +148,7 @@ public abstract class GenericLoopbackConnection : IAsyncDisposable /// If isFinal is false, the body is not completed and you can call SendResponseBodyAsync to send more. public abstract Task SendResponseAsync(HttpStatusCode statusCode = HttpStatusCode.OK, IList headers = null, string content = "", bool isFinal = true); /// Sends response headers. - public abstract Task SendResponseHeadersAsync(HttpStatusCode statusCode = HttpStatusCode.OK, IList headers = null); + public abstract Task SendResponseHeadersAsync(HttpStatusCode statusCode = HttpStatusCode.OK, IList headers = null, bool isTrailingHeader = false); /// Sends valid but incomplete headers. Once called, there is no way to continue the response past this point. public abstract Task SendPartialResponseHeadersAsync(HttpStatusCode statusCode = HttpStatusCode.OK, IList headers = null); /// Sends Response body after SendResponse was called with isFinal: false. diff --git a/src/libraries/Common/tests/System/Net/Http/Http2LoopbackConnection.cs b/src/libraries/Common/tests/System/Net/Http/Http2LoopbackConnection.cs index 72e5cd31d984ca..6f19c5f21faf3b 100644 --- a/src/libraries/Common/tests/System/Net/Http/Http2LoopbackConnection.cs +++ b/src/libraries/Common/tests/System/Net/Http/Http2LoopbackConnection.cs @@ -959,10 +959,10 @@ public override Task SendResponseAsync(HttpStatusCode statusCode = HttpStatusCod return SendResponseAsync(statusCode, headers, content, isFinal, requestId: 0); } - public override Task SendResponseHeadersAsync(HttpStatusCode statusCode = HttpStatusCode.OK, IList headers = null) + public override Task SendResponseHeadersAsync(HttpStatusCode statusCode = HttpStatusCode.OK, IList headers = null, bool isTrailingHeader = false) { int streamId = _lastStreamId; - return SendResponseHeadersAsync(streamId, endStream: false, statusCode, isTrailingHeader: false, endHeaders: true, headers); + return SendResponseHeadersAsync(streamId, endStream: isTrailingHeader, statusCode, isTrailingHeader: isTrailingHeader, endHeaders: true, headers); } public override Task SendPartialResponseHeadersAsync(HttpStatusCode statusCode = HttpStatusCode.OK, IList headers = null) diff --git a/src/libraries/Common/tests/System/Net/Http/Http3LoopbackConnection.cs b/src/libraries/Common/tests/System/Net/Http/Http3LoopbackConnection.cs index a8e18ae6fce781..27ebd955072b68 100644 --- a/src/libraries/Common/tests/System/Net/Http/Http3LoopbackConnection.cs +++ b/src/libraries/Common/tests/System/Net/Http/Http3LoopbackConnection.cs @@ -229,9 +229,15 @@ public override async Task SendResponseBodyAsync(byte[] content, bool isFinal = } } - public override Task SendResponseHeadersAsync(HttpStatusCode statusCode = HttpStatusCode.OK, IList headers = null) + public override async Task SendResponseHeadersAsync(HttpStatusCode statusCode = HttpStatusCode.OK, IList headers = null, bool isTrailingHeader = false) { - return _currentStream.SendResponseHeadersAsync(statusCode, headers); + await _currentStream.SendResponseHeadersAsync(statusCode: isTrailingHeader ? null : statusCode, headers); + + if (isTrailingHeader) + { + _currentStream.Stream.CompleteWrites(); + await DisposeCurrentStream().ConfigureAwait(false); + } } public override Task SendPartialResponseHeadersAsync(HttpStatusCode statusCode = HttpStatusCode.OK, IList headers = null) diff --git a/src/libraries/Common/tests/System/Net/Http/LoopbackServer.cs b/src/libraries/Common/tests/System/Net/Http/LoopbackServer.cs index e4fb8f027c1623..8b9da85bdd13c5 100644 --- a/src/libraries/Common/tests/System/Net/Http/LoopbackServer.cs +++ b/src/libraries/Common/tests/System/Net/Http/LoopbackServer.cs @@ -979,9 +979,21 @@ private string GetResponseHeaderString(HttpStatusCode statusCode, IList headers = null) + private string GetTrailerString(IList trailers) { - string headerString = GetResponseHeaderString(statusCode, headers); + StringBuilder bld = new StringBuilder(); + bld.Append("0\r\n"); + foreach (HttpHeaderData headerData in trailers) + { + bld.Append($"{headerData.Name}: {headerData.Value}\r\n"); + } + bld.Append("\r\n"); + return bld.ToString(); + } + + public override async Task SendResponseHeadersAsync(HttpStatusCode statusCode = HttpStatusCode.OK, IList headers = null, bool isTrailingHeader = false) + { + string headerString = isTrailingHeader ? GetTrailerString(headers) : GetResponseHeaderString(statusCode, headers); await SendResponseAsync(headerString).ConfigureAwait(false); } diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Headers.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Headers.cs index e81430a6492543..199517d313b1e1 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Headers.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Headers.cs @@ -596,11 +596,14 @@ await connection.SendResponseAsync(HttpStatusCode.OK, }); } - [ConditionalTheory] - [InlineData(false, "test\nxwow\nmore\n")] - [InlineData(false, "test\rwow\rmore\r\n")] - [InlineData(true, "one\0two\0three\0")] - public async Task SendAsync_InvalidCharactersInResponseHeader_ReplacedWithSpaces(bool testHttp11, string value) + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser))] + [InlineData(false, "test\nxwow\nmore\n", false)] + [InlineData(false, "test\rwow\rmore\r\n", false)] + [InlineData(true, "one\0two\0three\0", false)] + [InlineData(false, "test\nxwow\nmore\n", true)] + [InlineData(false, "test\rwow\rmore\r\n", true)] + [InlineData(true, "one\0two\0three\0", true)] + public async Task SendAsync_InvalidCharactersInResponseHeader_ReplacedWithSpaces(bool testHttp11, string value, bool testTrailers) { if (!testHttp11 && UseVersion == HttpVersion.Version11) { @@ -620,12 +623,30 @@ await LoopbackServerFactory.CreateClientAndServerAsync( }; using HttpResponseMessage response = await client.SendAsync(request); - Assert.Equal(expectedValue, response.Headers.GetValues("test").Single()); + HttpResponseHeaders headerCollection = testTrailers ? response.TrailingHeaders : response.Headers; + Assert.Equal(expectedValue, headerCollection.GetValues("test").Single()); }, async server => { - List headers = [new HttpHeaderData("test", value)]; - HttpRequestData requestData = await server.AcceptConnectionSendResponseAndCloseAsync(additionalHeaders: headers); + List? headers = testTrailers ? null : [new HttpHeaderData("test", value)]; + List? trailers = testTrailers ? [new HttpHeaderData("test", value)] : null; + string content = "hello"; + + if (testTrailers && UseVersion == HttpVersion.Version11) + { + headers = [new HttpHeaderData("Transfer-Encoding", "chunked")]; + content = $"{content.Length:X}\r\n{content}\r\n"; + } + + await server.AcceptConnectionAsync(async connection => + { + await connection.ReadRequestDataAsync(); + await connection.SendResponseAsync(headers: headers, content: content, isFinal: trailers is null); + if (trailers is { }) + { + await connection.SendResponseHeadersAsync(headers: trailers, isTrailingHeader: true); + } + }); }); } }