AspNetCore.Proxy: Hang when using a controller to proxy form
When proxying through a controller, there is a hang when handling a POST or PUT request with either a Content-Type: application/x-www-form-urlencoded or Content-Type: multipart/form-data header.
I’ve reproduced it starting with a simple Web API project, with the following changs:
- Adding
services.AddProxies()toConfigureServices(). - Removing
app.UseHttpsRedirection()fromConfigure()so we can avoid certificate issues. - Adding
app.RunProxy(proxy => proxy.UseHttp("http://localhost:5000/proxied"))to the end ofConfigure()(afterUseEndpoints()), so that we can try this proxy method, but only if the request doesn’t match a valid route. Deliberately adds to the path, so that the initial request doesn’t have to match a valid route when the proxied request does. - Creating the following controller, which handles that variant of proxying, and also has the destination we’re proxying to:
[ApiController]
public class ProxyController : ControllerBase {
[Route("proxy/{**rest}")]
public Task ProxyAsync(string rest) {
return this.HttpProxyAsync($"http://localhost:5000/{rest}");
}
[Route("echo")]
[Route("proxied/echo2")]
public async Task<ActionResult<string>> EchoAsync() {
using var sr = new StreamReader(Request.Body);
return await sr.ReadToEndAsync();
}
}
I’ve used an API testing client (in my case the Firefox extension RESTer) to send various requests to this at https://localhost:5001/proxy/echo (proxies to localhost:5000/echo via the controller). As long as the request does not have one of the mentioned Content-Type headers, or has not content, this works fine. But with either of these content types, it returns an exception:
Microsoft.AspNetCore.Server.Kestrel.Core.BadHttpRequestException: Reading the request body timed out due to data arriving too slowly. See MinRequestBodyDataRate.
at Microsoft.AspNetCore.Server.Kestrel.Core.BadHttpRequestException.Throw(RequestRejectionReason reason)
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.Http1ContentLengthMessageBody.ReadAsyncInternal(CancellationToken cancellationToken)
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpRequestStream.ReadAsyncInternal(Memory`1 buffer, CancellationToken cancellationToken)
at System.IO.StreamReader.ReadBufferAsync(CancellationToken cancellationToken)
at System.IO.StreamReader.ReadToEndAsyncInternal()
at ProxyTest.Controllers.ProxyController.EchoAsync() in /home/<removed>/Projects/ProxyTest/Controllers/ProxyController.cs:line 22
at lambda_method(Closure , Object )
at Microsoft.Extensions.Internal.ObjectMethodExecutorAwaitable.Awaiter.GetResult()
at Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor.AwaitableObjectResultExecutor.Execute(IActionResultTypeMapper mapper, ObjectMethodExecutor executor, Object controller, Object[] arguments)
at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeActionMethodAsync>g__Awaited|12_0(ControllerActionInvoker invoker, ValueTask`1 actionResultValueTask)
at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeNextActionFilterAsync>g__Awaited|10_0(ControllerActionInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Rethrow(ActionExecutedContextSealed context)
at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeInnerFilterAsync>g__Awaited|13_0(ControllerActionInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|19_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Awaited|17_0(ResourceInvoker invoker, Task task, IDisposable scope)
at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|6_0(Endpoint endpoint, Task requestTask, ILogger logger)
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware.Invoke(HttpContext context)
HEADERS
=======
Cache-Control: no-cache
Pragma: no-cache
Content-Type: application/x-www-form-urlencoded
Host: localhost:5000
TE: trailers
Request-Id: |b5df5be7-411c1e27a666778b.1.
Content-Length: 14
X-Forwarded-For: 127.0.0.1
X-Forwarded-Proto: https
X-Forwarded-Host: localhost:5001
Forwarded: proto=https;host=localhost:5001;by=127.0.0.1;for=127.0.0.1;
But everything works correctly when requesting https://localhost:5001/echo2 (doesn’t match a route, so proxies to localhost:5000/proxied/echo2).
This must be the controller mucking around with something when handling form data, but I don’t know what…
About this issue
- Original URL
- State: closed
- Created 4 years ago
- Comments: 16 (10 by maintainers)
Commits related to this issue
- Fix #57. — committed to twitchax/AspNetCore.Proxy by twitchax 4 years ago
Just did some more experimenting. Started by using the
EchoAsync()method above, checking out the request headers and in the process noticed theHttpRequest.Form– readingRequest.Formfirst meant the body was consumed, and vise versa (expected). Then I tried adding a string route parameter, withRoute("echo/{**rest}")andEchoAsync(string rest), to match theProxyAsync()method. NowRequest.Formalways had the data, and not the body. Adding[FromRoute]and such didn’t help. I had a look to see if I could find something to prevent reading the form, but I didn’t come up with anything much. So I recalled thatRequestdoes have request path stuff too – and also route values. So checked those out, and they work as expected. Keep the catchall on the route, don’t have any parameters / model binding at all, access the remaining path withRequest.RouteValues["rest"], and everything seems to work fine.So long story short, replace
Proxy()above with this, and it works.I’d prefer an option, and this seems a little fragile, but it’ll do for now.