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() to ConfigureServices().
  • Removing app.UseHttpsRedirection() from Configure() so we can avoid certificate issues.
  • Adding app.RunProxy(proxy => proxy.UseHttp("http://localhost:5000/proxied")) to the end of Configure() (after UseEndpoints()), 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

Most upvoted comments

Just did some more experimenting. Started by using the EchoAsync() method above, checking out the request headers and in the process noticed the HttpRequest.Form – reading Request.Form first meant the body was consumed, and vise versa (expected). Then I tried adding a string route parameter, with Route("echo/{**rest}") and EchoAsync(string rest), to match the ProxyAsync() method. Now Request.Form always 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 that Request does 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 with Request.RouteValues["rest"], and everything seems to work fine.

So long story short, replace Proxy() above with this, and it works.

[Route("proxy/{**rest}")]
public Task ProxyAsync() {
    var rest = Request.RouteValues["rest"];
    return this.HttpProxyAsync($"http://localhost:5000/{rest}");
}

I’d prefer an option, and this seems a little fragile, but it’ll do for now.