/// <summary> /// A callback that is invoked prior to sending the proxied request. All HttpRequestMessage fields are /// initialized except RequestUri, which will be initialized after the callback if no value is provided. /// See <see cref="RequestUtilities.MakeDestinationAddress(string, PathString, QueryString)"/> for constructing a custom request Uri. /// The string parameter represents the destination URI prefix that should be used when constructing the RequestUri. /// The headers are copied by the base implementation, excluding some protocol headers like HTTP/2 pseudo headers (":authority"). /// </summary> /// <param name="httpContext">The incoming request.</param> /// <param name="proxyRequest">The outgoing proxy request.</param> /// <param name="destinationPrefix">The uri prefix for the selected destination server which can be used to create the RequestUri.</param> public virtual ValueTask TransformRequestAsync(HttpContext httpContext, HttpRequestMessage proxyRequest, string destinationPrefix) { foreach (var header in httpContext.Request.Headers) { var headerName = header.Key; var headerValue = header.Value; if (RequestUtilities.ShouldSkipRequestHeader(headerName)) { continue; } RequestUtilities.AddHeader(proxyRequest, headerName, headerValue); } // https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.3 // If a message is received with both a Transfer-Encoding and a // Content-Length header field, the Transfer-Encoding overrides the // Content-Length. Such a message might indicate an attempt to // perform request smuggling (Section 9.5) or response splitting // (Section 9.4) and ought to be handled as an error. A sender MUST // remove the received Content-Length field prior to forwarding such // a message downstream. if (httpContext.Request.Headers.ContainsKey(HeaderNames.TransferEncoding) && httpContext.Request.Headers.ContainsKey(HeaderNames.ContentLength)) { proxyRequest.Content?.Headers.Remove(HeaderNames.ContentLength); } // https://datatracker.ietf.org/doc/html/rfc7540#section-8.1.2.2 // The only exception to this is the TE header field, which MAY be // present in an HTTP/2 request; when it is, it MUST NOT contain any // value other than "trailers". if (ProtocolHelper.IsHttp2OrGreater(httpContext.Request.Protocol)) { var te = httpContext.Request.Headers.GetCommaSeparatedValues(HeaderNames.TE); if (te != null) { for (var i = 0; i < te.Length; i++) { if (string.Equals(te[i], "trailers", StringComparison.OrdinalIgnoreCase)) { var added = proxyRequest.Headers.TryAddWithoutValidation(HeaderNames.TE, te[i]); Debug.Assert(added); break; } } } } return(default);
private static void CopyResponseHeaders(HttpContext httpContext, HttpHeaders source, IHeaderDictionary destination) { var isHttp2OrGreater = ProtocolHelper.IsHttp2OrGreater(httpContext.Request.Protocol); foreach (var header in source) { var headerName = header.Key; if (RequestUtilities.ShouldSkipResponseHeader(headerName, isHttp2OrGreater)) { continue; } destination.Append(headerName, header.Value.ToArray()); } }
private static Task CopyResponseStatusAndHeadersAsync(HttpResponseMessage source, HttpContext context, HttpTransformer transformer) { context.Response.StatusCode = (int)source.StatusCode; if (!ProtocolHelper.IsHttp2OrGreater(context.Request.Protocol)) { // Don't explicitly set the field if the default reason phrase is used if (source.ReasonPhrase != ReasonPhrases.GetReasonPhrase((int)source.StatusCode)) { context.Features.Get <IHttpResponseFeature>().ReasonPhrase = source.ReasonPhrase; } } // Copies headers return(transformer.TransformResponseAsync(context, source)); }
private StreamCopyHttpContent SetupRequestBodyCopy(HttpRequest request, bool isStreamingRequest, CancellationToken cancellation) { // If we generate an HttpContent without a Content-Length then for HTTP/1.1 HttpClient will add a Transfer-Encoding: chunked header // even if it's a GET request. Some servers reject requests containing a Transfer-Encoding header if they're not expecting a body. // Try to be as specific as possible about the client's intent to send a body. The one thing we don't want to do is to start // reading the body early because that has side-effects like 100-continue. var hasBody = true; var contentLength = request.Headers.ContentLength; var method = request.Method; #if NET var canHaveBodyFeature = request.HttpContext.Features.Get <IHttpRequestBodyDetectionFeature>(); if (canHaveBodyFeature != null) { // 5.0 servers provide a definitive answer for us. hasBody = canHaveBodyFeature.CanHaveBody; } else #endif // https://tools.ietf.org/html/rfc7230#section-3.3.3 // All HTTP/1.1 requests should have Transfer-Encoding or Content-Length. // Http.Sys/IIS will even add a Transfer-Encoding header to HTTP/2 requests with bodies for back-compat. // HTTP/1.0 Connection: close bodies are only allowed on responses, not requests. // https://tools.ietf.org/html/rfc1945#section-7.2.2 // // Transfer-Encoding overrides Content-Length per spec if (request.Headers.TryGetValue(HeaderNames.TransferEncoding, out var transferEncoding) && transferEncoding.Count == 1 && string.Equals("chunked", transferEncoding.ToString(), StringComparison.OrdinalIgnoreCase)) { hasBody = true; } else if (contentLength.HasValue) { hasBody = contentLength > 0; } // Kestrel HTTP/2: There are no required headers that indicate if there is a request body so we need to sniff other fields. else if (!ProtocolHelper.IsHttp2OrGreater(request.Protocol)) { hasBody = false; } // https://tools.ietf.org/html/rfc7231#section-4.3.1 // A payload within a GET/HEAD/DELETE/CONNECT request message has no defined semantics; sending a payload body on a // GET/HEAD/DELETE/CONNECT request might cause some existing implementations to reject the request. // https://tools.ietf.org/html/rfc7231#section-4.3.8 // A client MUST NOT send a message body in a TRACE request. else if (HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsDelete(method) || HttpMethods.IsConnect(method) || HttpMethods.IsTrace(method)) { hasBody = false; } // else hasBody defaults to true StreamCopyHttpContent requestContent = null; if (hasBody) { if (isStreamingRequest) { DisableMinRequestBodyDataRateAndMaxRequestBodySize(request.HttpContext); } // Note on `autoFlushHttpClientOutgoingStream: isStreamingRequest`: // The.NET Core HttpClient stack keeps its own buffers on top of the underlying outgoing connection socket. // We flush those buffers down to the socket on every write when this is set, // but it does NOT result in calls to flush on the underlying socket. // This is necessary because we proxy http2 transparently, // and we are deliberately unaware of packet structure used e.g. in gRPC duplex channels. // Because the sockets aren't flushed, the perf impact of this choice is expected to be small. // Future: It may be wise to set this to true for *all* http2 incoming requests, // but for now, out of an abundance of caution, we only do it for requests that look like gRPC. requestContent = new StreamCopyHttpContent( source: request.Body, autoFlushHttpClientOutgoingStream: isStreamingRequest, clock: _clock, cancellation: cancellation); } return(requestContent); }
/// <summary> /// Proxies the incoming request to the destination server, and the response back to the client. /// </summary> /// <remarks> /// In what follows, as well as throughout in Reverse Proxy, we consider /// the following picture as illustrative of the Proxy. /// <code> /// +-------------------+ /// | Destination + /// +-------------------+ /// ▲ | /// (b) | | (c) /// | ▼ /// +-------------------+ /// | Proxy + /// +-------------------+ /// ▲ | /// (a) | | (d) /// | ▼ /// +-------------------+ /// | Client + /// +-------------------+ /// </code> /// /// (a) and (b) show the *request* path, going from the client to the target. /// (c) and (d) show the *response* path, going from the destination back to the client. /// /// Normal proxying comprises the following steps: /// (0) Disable ASP .NET Core limits for streaming requests /// (1) Create outgoing HttpRequestMessage /// (2) Setup copy of request body (background) Client --► Proxy --► Destination /// (3) Copy request headers Client --► Proxy --► Destination /// (4) Send the outgoing request using HttpMessageInvoker Client --► Proxy --► Destination /// (5) Copy response status line Client ◄-- Proxy ◄-- Destination /// (6) Copy response headers Client ◄-- Proxy ◄-- Destination /// (7-A) Check for a 101 upgrade response, this takes care of WebSockets as well as any other upgradeable protocol. /// (7-A-1) Upgrade client channel Client ◄--- Proxy ◄--- Destination /// (7-A-2) Copy duplex streams and return Client ◄--► Proxy ◄--► Destination /// (7-B) Copy (normal) response body Client ◄-- Proxy ◄-- Destination /// (8) Copy response trailer headers and finish response Client ◄-- Proxy ◄-- Destination /// (9) Wait for completion of step 2: copying request body Client --► Proxy --► Destination /// /// ASP .NET Core (Kestrel) will finally send response trailers (if any) /// after we complete the steps above and relinquish control. /// </remarks> public async ValueTask <ForwarderError> SendAsync( HttpContext context, string destinationPrefix, HttpMessageInvoker httpClient, ForwarderRequestConfig requestConfig, HttpTransformer transformer) { _ = context ?? throw new ArgumentNullException(nameof(context)); _ = destinationPrefix ?? throw new ArgumentNullException(nameof(destinationPrefix)); _ = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _ = requestConfig ?? throw new ArgumentNullException(nameof(requestConfig)); _ = transformer ?? throw new ArgumentNullException(nameof(transformer)); // HttpClient overload for SendAsync changes response behavior to fully buffered which impacts performance // See discussion in https://github.com/microsoft/reverse-proxy/issues/458 if (httpClient is HttpClient) { throw new ArgumentException($"The http client must be of type HttpMessageInvoker, not HttpClient", nameof(httpClient)); } ForwarderTelemetry.Log.ForwarderStart(destinationPrefix); var activityCancellationSource = ActivityCancellationTokenSource.Rent(requestConfig?.ActivityTimeout ?? DefaultTimeout, context.RequestAborted); try { var isClientHttp2OrGreater = ProtocolHelper.IsHttp2OrGreater(context.Request.Protocol); // NOTE: We heuristically assume gRPC-looking requests may require streaming semantics. // See https://github.com/microsoft/reverse-proxy/issues/118 for design discussion. var isStreamingRequest = isClientHttp2OrGreater && ProtocolHelper.IsGrpcContentType(context.Request.ContentType); // :: Step 1-3: Create outgoing HttpRequestMessage var(destinationRequest, requestContent) = await CreateRequestMessageAsync( context, destinationPrefix, transformer, requestConfig, isStreamingRequest, activityCancellationSource); // :: Step 4: Send the outgoing request using HttpClient HttpResponseMessage destinationResponse; try { ForwarderTelemetry.Log.ForwarderStage(ForwarderStage.SendAsyncStart); destinationResponse = await httpClient.SendAsync(destinationRequest, activityCancellationSource.Token); ForwarderTelemetry.Log.ForwarderStage(ForwarderStage.SendAsyncStop); // Reset the timeout since we received the response headers. activityCancellationSource.ResetTimeout(); } catch (Exception requestException) { return(await HandleRequestFailureAsync(context, requestContent, requestException, transformer, activityCancellationSource)); } Log.ResponseReceived(_logger, destinationResponse); try { // :: Step 5: Copy response status line Client ◄-- Proxy ◄-- Destination // :: Step 6: Copy response headers Client ◄-- Proxy ◄-- Destination var copyBody = await CopyResponseStatusAndHeadersAsync(destinationResponse, context, transformer); if (!copyBody) { // The transforms callback decided that the response body should be discarded. destinationResponse.Dispose(); if (requestContent is not null && requestContent.InProgress) { activityCancellationSource.Cancel(); await requestContent.ConsumptionTask; } return(ForwarderError.None); } } catch (Exception ex) { destinationResponse.Dispose(); if (requestContent is not null && requestContent.InProgress) { activityCancellationSource.Cancel(); await requestContent.ConsumptionTask; } ReportProxyError(context, ForwarderError.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(ForwarderError.ResponseHeaders); } // :: 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) { Debug.Assert(requestContent?.Started != true); return(await HandleUpgradedResponse(context, destinationResponse, activityCancellationSource)); } // NOTE: it may *seem* wise to call `context.Response.StartAsync()` at this point // since it looks like we are ready to send back response headers // (and this might help reduce extra delays while we wait to receive the body from the destination). // HOWEVER, this would produce the wrong result if it turns out that there is no content // from the destination -- instead of sending headers and terminating the stream at once, // we would send headers thinking a body may be coming, and there is none. // This is problematic on gRPC connections when the destination server encounters an error, // in which case it immediately returns the response headers and trailing headers, but no content, // and clients misbehave if the initial headers response does not indicate stream end. // :: Step 7-B: Copy response body Client ◄-- Proxy ◄-- Destination var(responseBodyCopyResult, responseBodyException) = await CopyResponseBodyAsync(destinationResponse.Content, context.Response.Body, activityCancellationSource); if (responseBodyCopyResult != StreamCopyResult.Success) { return(await HandleResponseBodyErrorAsync(context, requestContent, responseBodyCopyResult, responseBodyException !, activityCancellationSource)); } // :: Step 8: Copy response trailer headers and finish response Client ◄-- Proxy ◄-- Destination await CopyResponseTrailingHeadersAsync(destinationResponse, context, transformer); if (isStreamingRequest) { // NOTE: We must call `CompleteAsync` so that Kestrel will flush all bytes to the client. // In the case where there was no response body, // this is also when headers and trailing headers are sent to the client. // Without this, the client might wait forever waiting for response bytes, // while we might wait forever waiting for request bytes, // leading to a stuck connection and no way to make progress. await context.Response.CompleteAsync(); } // :: Step 9: Wait for completion of step 2: copying request body Client --► Proxy --► Destination // NOTE: It is possible for the request body to NOT be copied even when there was an incoming requet body, // e.g. when the request includes header `Expect: 100-continue` and the destination produced a non-1xx response. // We must only wait for the request body to complete if it actually started, // otherwise we run the risk of waiting indefinitely for a task that will never complete. if (requestContent is not null && requestContent.Started) { var(requestBodyCopyResult, requestBodyException) = await requestContent.ConsumptionTask; if (requestBodyCopyResult != StreamCopyResult.Success) { // The response succeeded. If there was a request body error then it was probably because the client or destination decided // to cancel it. Report as low severity. var error = requestBodyCopyResult switch { StreamCopyResult.InputError => ForwarderError.RequestBodyClient, StreamCopyResult.OutputError => ForwarderError.RequestBodyDestination, StreamCopyResult.Canceled => ForwarderError.RequestBodyCanceled, _ => throw new NotImplementedException(requestBodyCopyResult.ToString()) }; ReportProxyError(context, error, requestBodyException !); return(error); } } } finally { activityCancellationSource.Return(); ForwarderTelemetry.Log.ForwarderStop(context.Response.StatusCode); } return(ForwarderError.None); }