Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

E2E tests for different header encodings #760

Merged
merged 9 commits into from
Feb 26, 2021
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion docs/docfx/articles/proxyhttpclientconfig.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ HTTP client configuration is based on [ProxyHttpClientOptions](xref:Microsoft.Re
"Store": "<string>",
"Location": "<string>",
"AllowInvalid": "<bool>"
}
},
"RequestHeaderEncoding": "<encoding-name>"
}
```

Expand Down Expand Up @@ -75,6 +76,32 @@ Configuration settings:
}

```
- RequestHeaderEncoding - enables other than ASCII encoding for outgoing request headers. Setting this value will leverage [`SocketsHttpHandler.RequestHeaderEncodingSelector`](https://docs.microsoft.com/en-us/dotnet/api/system.net.http.socketshttphandler.requestheaderencodingselector?view=net-5.0) and use the selected encoding for all headers. If you need more granular approach, please use custom `IProxyHttpClientFactory`. The value is then parsed by [`Encoding.GetEncoding`](https://docs.microsoft.com/en-us/dotnet/api/system.text.encoding.getencoding?view=net-5.0#System_Text_Encoding_GetEncoding_System_String_), use values like: "utf-8", "iso-8859-1", etc. **This setting is only available for .NET 5.0.**
```JSON
"RequestHeaderEncoding": "utf-8"
```
If you're using an encoding other than "ascii" (or "utf-8" for Kestrel) you also need to set your server to accept requests with such headers. For example, use [`KestrelServerOptions.RequestHeaderEncodingSelector`](https://docs.microsoft.com/en-us/dotnet/api/Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions.RequestHeaderEncodingSelector?view=aspnetcore-5.0) to set up Kestrel to accept Latin1 ("iso-8859-1") headers:
```C#
private static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
})
.ConfigureWebHost(webBuilder => webBuilder.UseKestrel(kestrel =>
{
kestrel.RequestHeaderEncodingSelector = _ => Encoding.Latin1;
}));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConfigureWebHost and ConfigureWebHostDefaults are redundant. Also call ConfigureKestrel rather than UseKestrel here, UseKestrel was already called by ConfigureWebHostDefaults.

Suggested change
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
})
.ConfigureWebHost(webBuilder => webBuilder.UseKestrel(kestrel =>
{
kestrel.RequestHeaderEncodingSelector = _ => Encoding.Latin1;
}));
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
webBuilder.ConfigureKestrel(kestrelOptions =>
{
kestrelOptions.RequestHeaderEncodingSelector = _ => Encoding.Latin1;
})
});

```

For .NET Core 3.1, Latin1 ("iso-8859-1") is the only non-ASCII header encoding that can be accepted and only via application wide switch:
```C#
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.AllowLatin1Headers", true);
Tratcher marked this conversation as resolved.
Show resolved Hide resolved
AppContext.SetSwitch("Microsoft.AspNetCore.Server.Kestrel.Latin1RequestHeaders", true);
```

At the moment, there is no solution for changing encoding for response headers, only ASCII is accepted.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... in Kestrel.

Link to dotnet/aspnetcore#26334.



### HttpRequest
HTTP request configuration is based on [ProxyHttpRequestOptions](xref:Microsoft.ReverseProxy.Abstractions.ProxyHttpRequestOptions) and represented by the following configuration schema.
Expand Down Expand Up @@ -256,6 +283,12 @@ public class CustomProxyHttpClientFactory : IProxyHttpClientFactory
{
handler.SslOptions.RemoteCertificateValidationCallback = (sender, cert, chain, errors) => cert.Subject == "dev.mydomain";
}
#if NET
if (newClientOptions.RequestHeaderEncoding != null)
{
handler.RequestHeaderEncodingSelector = (_, _) => newClientOptions.RequestHeaderEncoding;
}
#endif

return new HttpMessageInvoker(handler, disposeHandler: true);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using System.Text;

namespace Microsoft.ReverseProxy.Abstractions
{
Expand Down Expand Up @@ -50,6 +51,11 @@ public sealed record ProxyHttpClientOptions
// is reached on all existing connections.
/// </summary>
public bool? EnableMultipleHttp2Connections { get; init; }

/// <summary>
/// Enables non-ASCII header encoding for outgoing requests.
/// </summary>
public Encoding RequestHeaderEncoding { get; init; }
#endif

/// <inheritdoc />
Expand All @@ -61,13 +67,15 @@ public bool Equals(ProxyHttpClientOptions other)
}

return SslProtocols == other.SslProtocols
&& CertEquals(ClientCertificate, other.ClientCertificate)
&& DangerousAcceptAnyServerCertificate == other.DangerousAcceptAnyServerCertificate
&& MaxConnectionsPerServer == other.MaxConnectionsPerServer
&& CertEquals(ClientCertificate, other.ClientCertificate)
&& DangerousAcceptAnyServerCertificate == other.DangerousAcceptAnyServerCertificate
&& MaxConnectionsPerServer == other.MaxConnectionsPerServer
#if NET
&& EnableMultipleHttp2Connections == other.EnableMultipleHttp2Connections
&& EnableMultipleHttp2Connections == other.EnableMultipleHttp2Connections
// Comparing by reference is fine here since Encoding.GetEncoding returns the same instance for each encoding.
&& RequestHeaderEncoding == other.RequestHeaderEncoding
#endif
&& ActivityContextHeaders == other.ActivityContextHeaders;
&& ActivityContextHeaders == other.ActivityContextHeaders;
}

private static bool CertEquals(X509Certificate2 certificate1, X509Certificate2 certificate2)
Expand All @@ -94,6 +102,7 @@ public override int GetHashCode()
MaxConnectionsPerServer,
#if NET
EnableMultipleHttp2Connections,
RequestHeaderEncoding,
#endif
ActivityContextHeaders);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.Net.Http;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -327,6 +328,7 @@ private ProxyHttpClientOptions CreateProxyHttpClientOptions(IConfigurationSectio
MaxConnectionsPerServer = section.ReadInt32(nameof(ProxyHttpClientOptions.MaxConnectionsPerServer)),
#if NET
EnableMultipleHttp2Connections = section.ReadBool(nameof(ProxyHttpClientOptions.EnableMultipleHttp2Connections)),
RequestHeaderEncoding = section[nameof(ProxyHttpClientOptions.RequestHeaderEncoding)] is string encoding ? Encoding.GetEncoding(encoding) : null,
#endif
ActivityContextHeaders = section.ReadEnum<ActivityContextHeaders>(nameof(ProxyHttpClientOptions.ActivityContextHeaders))
};
Expand Down
20 changes: 16 additions & 4 deletions src/ReverseProxy/Service/Proxy/HttpProxy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,20 @@ public async Task ProxyAsync(
throw new InvalidOperationException("Proxying the Client request body to the Destination server hasn't started. This is a coding defect.");
}

// :: Step 5: Copy response status line Client ◄-- Proxy ◄-- Destination
// :: Step 6: Copy response headers Client ◄-- Proxy ◄-- Destination
await CopyResponseStatusAndHeadersAsync(destinationResponse, context, transformer);
try
{
// :: Step 5: Copy response status line Client ◄-- Proxy ◄-- Destination
// :: Step 6: Copy response headers Client ◄-- Proxy ◄-- Destination
await CopyResponseStatusAndHeadersAsync(destinationResponse, context, transformer);
}
catch (Exception ex)
{
ReportProxyError(context, ProxyError.ResponseHeaders, ex);
// Clear the response since status code, reason and some headers might have already been copied and we want clean 502 response.
context.Response.Clear();
context.Response.StatusCode = StatusCodes.Status502BadGateway;
return;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sure to dispose the destinationResponse to avoid a leak.

}

// :: Step 7-A: Check for a 101 upgrade response, this takes care of WebSockets as well as any other upgradeable protocol.
if (destinationResponse.StatusCode == HttpStatusCode.SwitchingProtocols)
Expand Down Expand Up @@ -285,7 +296,7 @@ public async Task ProxyAsync(
// Allow someone to custom build the request uri, otherwise provide a default for them.
var request = context.Request;
destinationRequest.RequestUri ??= RequestUtilities.MakeDestinationAddress(destinationPrefix, request.Path, request.QueryString);

Log.Proxying(_logger, destinationRequest.RequestUri);

// TODO: What if they replace the HttpContent object? That would mess with our tracking and error handling.
Expand Down Expand Up @@ -689,6 +700,7 @@ private static string GetMessage(ProxyError error)
ProxyError.ResponseBodyCanceled => "Copying the response body was canceled.",
ProxyError.ResponseBodyClient => "The client reported an error when copying the response body.",
ProxyError.ResponseBodyDestination => "The destination reported an error when copying the response body.",
ProxyError.ResponseHeaders => "The destination returned a response that cannot be proxied back to the client.",
ProxyError.UpgradeRequestCanceled => "Copying the upgraded request body was canceled.",
ProxyError.UpgradeRequestClient => "The client reported an error when copying the upgraded request body.",
ProxyError.UpgradeRequestDestination => "The destination reported an error when copying the upgraded request body.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ public HttpMessageInvoker CreateClient(ProxyHttpClientContext context)
{
handler.EnableMultipleHttp2Connections = newClientOptions.EnableMultipleHttp2Connections.Value;
}
if (newClientOptions.RequestHeaderEncoding != null)
{
handler.RequestHeaderEncodingSelector = (_, _) => newClientOptions.RequestHeaderEncoding;
}
#endif

Log.ProxyClientCreated(_logger, context.ClusterId);
Expand Down
5 changes: 5 additions & 0 deletions src/ReverseProxy/Service/Proxy/ProxyError.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ public enum ProxyError : int
/// </summary>
RequestBodyDestination,

/// <summary>
/// Failed to copy response headers.
/// </summary>
ResponseHeaders,

/// <summary>
/// Canceled while copying the response body.
/// </summary>
Expand Down
100 changes: 60 additions & 40 deletions test/ReverseProxy.FunctionalTests/Common/TestEnvironment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
Expand All @@ -25,13 +26,14 @@ public class TestEnvironment
private readonly Action<IApplicationBuilder> _configureProxyApp;
private readonly HttpProtocols _proxyProtocol;
private readonly bool _useHttpsOnDestination;
private readonly Encoding _headerEncoding;

public string ClusterId { get; set; } = "cluster1";

public TestEnvironment(
RequestDelegate destinationGetDelegate,
Action<IReverseProxyBuilder> configureProxy, Action<IApplicationBuilder> configureProxyApp,
HttpProtocols proxyProtocol = HttpProtocols.Http1AndHttp2, bool useHttpsOnDestination = false)
HttpProtocols proxyProtocol = HttpProtocols.Http1AndHttp2, bool useHttpsOnDestination = false, Encoding headerEncoding = null)
: this(
destinationServices => { },
destinationApp =>
Expand All @@ -41,95 +43,113 @@ public TestEnvironment(
configureProxy,
configureProxyApp,
proxyProtocol,
useHttpsOnDestination)
useHttpsOnDestination,
headerEncoding)
{ }

public TestEnvironment(
Action<IServiceCollection> configureDestinationServices, Action<IApplicationBuilder> configureDestinationApp,
Action<IReverseProxyBuilder> configureProxy, Action<IApplicationBuilder> configureProxyApp,
HttpProtocols proxyProtocol = HttpProtocols.Http1AndHttp2, bool useHttpsOnDestination = false)
HttpProtocols proxyProtocol = HttpProtocols.Http1AndHttp2, bool useHttpsOnDestination = false, Encoding headerEncoding = null)
{
_configureDestinationServices = configureDestinationServices;
_configureDestinationApp = configureDestinationApp;
_configureProxy = configureProxy;
_configureProxyApp = configureProxyApp;
_proxyProtocol = proxyProtocol;
_useHttpsOnDestination = useHttpsOnDestination;
_headerEncoding = headerEncoding;
}

public async Task Invoke(Func<string, Task> clientFunc, CancellationToken cancellationToken = default)
{
using var destination = CreateHost(HttpProtocols.Http1AndHttp2, _useHttpsOnDestination, _configureDestinationServices, _configureDestinationApp);
using var destination = CreateHost(HttpProtocols.Http1AndHttp2, _useHttpsOnDestination, _headerEncoding, _configureDestinationServices, _configureDestinationApp);
await destination.StartAsync(cancellationToken);

using var proxy = CreateHost(_proxyProtocol, false,
using var proxy = CreateProxy(_proxyProtocol, _useHttpsOnDestination, _headerEncoding, ClusterId, destination.GetAddress(), _configureProxy, _configureProxyApp);
await proxy.StartAsync(cancellationToken);

try
{
await clientFunc(proxy.GetAddress());
}
finally
{
await proxy.StopAsync(cancellationToken);
await destination.StopAsync(cancellationToken);
}
}

public static IHost CreateProxy(HttpProtocols protocols, bool useHttps, Encoding requestHeaderEncoding, string clusterId, string destinationAddress,
Action<IReverseProxyBuilder> configureProxy, Action<IApplicationBuilder> configureProxyApp)
{
return CreateHost(protocols, false, requestHeaderEncoding,
services =>
{
var proxyRoute = new ProxyRoute
{
RouteId = "route1",
ClusterId = ClusterId,
ClusterId = clusterId,
Match = new ProxyMatch { Path = "/{**catchall}" }
};

var cluster = new Cluster
{
Id = ClusterId,
Id = clusterId,
Destinations = new Dictionary<string, Destination>(StringComparer.OrdinalIgnoreCase)
{
{ "destination1", new Destination() { Address = destination.GetAddress() } }
{ "destination1", new Destination() { Address = destinationAddress } }
},
HttpClient = new ProxyHttpClientOptions
{
DangerousAcceptAnyServerCertificate = _useHttpsOnDestination
DangerousAcceptAnyServerCertificate = useHttps,
#if NET
RequestHeaderEncoding = requestHeaderEncoding,
#endif
}
};

var proxyBuilder = services.AddReverseProxy().LoadFromMemory(new[] { proxyRoute }, new[] { cluster });
_configureProxy(proxyBuilder);
configureProxy(proxyBuilder);
},
app =>
{
_configureProxyApp(app);
configureProxyApp(app);
app.UseRouting();
app.UseEndpoints(builder =>
{
builder.MapReverseProxy();
});
});
await proxy.StartAsync(cancellationToken);

try
{
await clientFunc(proxy.GetAddress());
}
finally
{
await proxy.StopAsync(cancellationToken);
await destination.StopAsync(cancellationToken);
}
}

private static IHost CreateHost(HttpProtocols protocols, bool useHttps, Action<IServiceCollection> configureServices, Action<IApplicationBuilder> configureApp)
private static IHost CreateHost(HttpProtocols protocols, bool useHttps, Encoding requestHeaderEncoding,
Action<IServiceCollection> configureServices, Action<IApplicationBuilder> configureApp)
{
return new HostBuilder()
.ConfigureWebHost(webHostBuilder =>
{
webHostBuilder
.ConfigureServices(configureServices)
.UseKestrel(kestrel =>
{
kestrel.Listen(IPAddress.Loopback, 0, listenOptions =>
{
listenOptions.Protocols = protocols;
if (useHttps)
{
listenOptions.UseHttps(TestResources.GetTestCertificate());
}
});
})
.Configure(configureApp);
}).Build();
.ConfigureWebHost(webHostBuilder =>
{
webHostBuilder
.ConfigureServices(configureServices)
.UseKestrel(kestrel =>
{
#if NET
if (requestHeaderEncoding != null)
{
kestrel.RequestHeaderEncodingSelector = _ => requestHeaderEncoding;
}
#endif
kestrel.Listen(IPAddress.Loopback, 0, listenOptions =>
{
listenOptions.Protocols = protocols;
if (useHttps)
{
listenOptions.UseHttps(TestResources.GetTestCertificate());
}
});
})
.Configure(configureApp);
}).Build();
}
}
}
Loading