From e1d868400f73aa36e0472ca97d5a93869d41a3e4 Mon Sep 17 00:00:00 2001 From: Aaron Date: Fri, 31 Jul 2020 15:14:07 -0700 Subject: [PATCH 1/5] Fix #57. --- src/Core/Extensions/Http.cs | 9 ++++++--- src/Core/Helpers/Helpers.cs | 9 +++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/Core/Extensions/Http.cs b/src/Core/Extensions/Http.cs index d419be1..c19a20c 100644 --- a/src/Core/Extensions/Http.cs +++ b/src/Core/Extensions/Http.cs @@ -81,11 +81,14 @@ private static HttpRequestMessage CreateProxiedHttpRequest(this HttpContext cont !HttpMethods.IsDelete(requestMethod) && !HttpMethods.IsTrace(requestMethod)) { - requestMessage.Content = new StreamContent(request.Body); + if (request.HasFormContentType) + requestMessage.Content = request.Form.ToStreamContent(); + else + requestMessage.Content = new StreamContent(request.Body); } // Copy the request headers. - foreach (var header in context.Request.Headers) + foreach (var header in request.Headers) { if (!requestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray())) requestMessage.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); @@ -98,7 +101,7 @@ private static HttpRequestMessage CreateProxiedHttpRequest(this HttpContext cont // Set destination and method. requestMessage.Headers.Host = uri.Authority; requestMessage.RequestUri = uri; - requestMessage.Method = new HttpMethod(request.Method); + requestMessage.Method = new HttpMethod(requestMethod); return requestMessage; } diff --git a/src/Core/Helpers/Helpers.cs b/src/Core/Helpers/Helpers.cs index 97eac2e..84f9b71 100644 --- a/src/Core/Helpers/Helpers.cs +++ b/src/Core/Helpers/Helpers.cs @@ -1,6 +1,10 @@ using System; using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; using System.Threading.Tasks; +using System.Web; using Microsoft.AspNetCore.Http; namespace AspNetCore.Proxy @@ -67,5 +71,10 @@ internal static string TrimTrailingSlashes(this string s) return s.Substring(0, s.Length - count); } + + internal static StreamContent ToStreamContent(this IFormCollection collection) + { + return new StreamContent(new MemoryStream(HttpUtility.UrlDecodeToBytes(string.Join("&", collection.Select(f => $"{f.Key}={f.Value}"))))); + } } } \ No newline at end of file From 45da1f9a5c042f3b18fd9bc50bf6b91d0bd823c5 Mon Sep 17 00:00:00 2001 From: Karl Date: Mon, 3 Aug 2020 13:09:48 +1200 Subject: [PATCH 2/5] Improve handling of form bodies. --- src/Core/Extensions/Http.cs | 8 +++++++- src/Core/Helpers/Helpers.cs | 30 ++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/Core/Extensions/Http.cs b/src/Core/Extensions/Http.cs index c19a20c..b1fb8cf 100644 --- a/src/Core/Extensions/Http.cs +++ b/src/Core/Extensions/Http.cs @@ -74,6 +74,7 @@ private static HttpRequestMessage CreateProxiedHttpRequest(this HttpContext cont var requestMessage = new HttpRequestMessage(); var requestMethod = request.Method; + var usesStreamContent = true; // When using other content types, they specify the Content-Type header, and may also change the Content-Length. // Write to request content, when necessary. if (!HttpMethods.IsGet(requestMethod) && @@ -82,7 +83,10 @@ private static HttpRequestMessage CreateProxiedHttpRequest(this HttpContext cont !HttpMethods.IsTrace(requestMethod)) { if (request.HasFormContentType) - requestMessage.Content = request.Form.ToStreamContent(); + { + usesStreamContent = false; + requestMessage.Content = request.Form.ToHttpContent(request.ContentType); + } else requestMessage.Content = new StreamContent(request.Body); } @@ -90,6 +94,8 @@ private static HttpRequestMessage CreateProxiedHttpRequest(this HttpContext cont // Copy the request headers. foreach (var header in request.Headers) { + if (!usesStreamContent && (header.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase) || header.Key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase))) + continue; if (!requestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray())) requestMessage.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); } diff --git a/src/Core/Helpers/Helpers.cs b/src/Core/Helpers/Helpers.cs index 84f9b71..1c9dbe0 100644 --- a/src/Core/Helpers/Helpers.cs +++ b/src/Core/Helpers/Helpers.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using System.Web; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; namespace AspNetCore.Proxy { @@ -72,9 +73,34 @@ internal static string TrimTrailingSlashes(this string s) return s.Substring(0, s.Length - count); } - internal static StreamContent ToStreamContent(this IFormCollection collection) + internal static HttpContent ToHttpContent(this IFormCollection collection, string contentType) { - return new StreamContent(new MemoryStream(HttpUtility.UrlDecodeToBytes(string.Join("&", collection.Select(f => $"{f.Key}={f.Value}"))))); + if (string.IsNullOrWhiteSpace(contentType) || contentType.Equals("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)) + return new FormUrlEncodedContent(collection.SelectMany(formItemList => formItemList.Value.Select(value => new KeyValuePair(formItemList.Key, value)))); + + if (!contentType.StartsWith("multipart/form-data;", StringComparison.OrdinalIgnoreCase)) + throw new Exception($"Unknown form content type \"{contentType[0]}\""); + + const string boundary = "boundary="; + var boundaryIndex = contentType.IndexOf(boundary, StringComparison.OrdinalIgnoreCase); + if (boundaryIndex < 0) + throw new Exception("Could not find multipart boundary"); + var delimiter = contentType.Substring(boundaryIndex + boundary.Length); + + var multipart = new MultipartFormDataContent(delimiter); + foreach (var formVal in collection) + { + foreach (var value in formVal.Value) + multipart.Add(new StringContent(value), formVal.Key); + } + foreach (var file in collection.Files) + { + var content = new StreamContent(file.OpenReadStream()); + foreach (var header in file.Headers) + content.Headers.TryAddWithoutValidation(header.Key, (IEnumerable)header.Value); + multipart.Add(content, file.Name, file.FileName); + } + return multipart; } } } \ No newline at end of file From 79af30bebbb99ef9c44b21f89a6dce6cef7b82cf Mon Sep 17 00:00:00 2001 From: Karl Date: Tue, 4 Aug 2020 10:33:18 +1200 Subject: [PATCH 3/5] Remove quotes around the multipart boundary. For some reason .Net Core quotes the boundary, but objects to being given a quoted boundary. --- src/Core/Helpers/Helpers.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Helpers/Helpers.cs b/src/Core/Helpers/Helpers.cs index 1c9dbe0..0a67b8e 100644 --- a/src/Core/Helpers/Helpers.cs +++ b/src/Core/Helpers/Helpers.cs @@ -85,7 +85,7 @@ internal static HttpContent ToHttpContent(this IFormCollection collection, strin var boundaryIndex = contentType.IndexOf(boundary, StringComparison.OrdinalIgnoreCase); if (boundaryIndex < 0) throw new Exception("Could not find multipart boundary"); - var delimiter = contentType.Substring(boundaryIndex + boundary.Length); + var delimiter = contentType.Substring(boundaryIndex + boundary.Length).Trim('"'); var multipart = new MultipartFormDataContent(delimiter); foreach (var formVal in collection) From 0395032fdbab74bb5cc6103ac73363f732b5de86 Mon Sep 17 00:00:00 2001 From: Karl Date: Tue, 4 Aug 2020 10:44:26 +1200 Subject: [PATCH 4/5] Add test for multipart/form-data --- src/Test/Http/HttpHelpers.cs | 6 ++++++ src/Test/Http/HttpIntegrationTests.cs | 29 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/Test/Http/HttpHelpers.cs b/src/Test/Http/HttpHelpers.cs index 8fde5df..240c3ac 100644 --- a/src/Test/Http/HttpHelpers.cs +++ b/src/Test/Http/HttpHelpers.cs @@ -81,6 +81,12 @@ public Task ProxyPostRequest() return this.HttpProxyAsync("https://jsonplaceholder.typicode.com/posts"); } + [Route("api/multipart")] + public Task ProxyPostMultipartRequest() + { + return this.HttpProxyAsync("https://httpbin.org/post"); + } + [Route("api/catchall/{**rest}")] public Task ProxyCatchAll(string rest) { diff --git a/src/Test/Http/HttpIntegrationTests.cs b/src/Test/Http/HttpIntegrationTests.cs index 114ca84..e716a98 100644 --- a/src/Test/Http/HttpIntegrationTests.cs +++ b/src/Test/Http/HttpIntegrationTests.cs @@ -67,6 +67,35 @@ public async Task CanProxyControllerPostWithFormRequest() Assert.Equal("321", json["abc"]); } + [Fact] + public async Task CanProxyControllerPostWithFormAndFilesRequest() + { + var content = new MultipartFormDataContent(); + content.Add(new StringContent("123"), "xyz"); + content.Add(new StringContent("456"), "xyz"); + content.Add(new StringContent("321"), "abc"); + const string fileString = "This is a test file."; + var fileContent = new StreamContent(new System.IO.MemoryStream(Encoding.UTF8.GetBytes(fileString))); + content.Add(fileContent, "testFile", "Test file.txt"); + var response = await _client.PostAsync("api/multipart", content); + response.EnsureSuccessStatusCode(); + + var responseString = await response.Content.ReadAsStringAsync(); + var json = JObject.Parse(responseString); + + var form = Assert.IsAssignableFrom(json["form"]); + Assert.Equal(2, form.Count); + var xyz = Assert.IsAssignableFrom(form["xyz"]); + Assert.Equal(2, xyz.Count); + Assert.Equal("123", xyz[0]); + Assert.Equal("456", xyz[1]); + Assert.Equal("321", form["abc"]); + + var files = Assert.IsAssignableFrom(json["files"]); + Assert.Single(files); + Assert.Equal(fileString, files["testFile"]); + } + [Fact] public async Task CanProxyControllerCatchAllPostWithFormRequest() { From e44b8b530e261df8c271aed901b9543267f4e513 Mon Sep 17 00:00:00 2001 From: Karl Date: Wed, 5 Aug 2020 11:28:26 +1200 Subject: [PATCH 5/5] Use MediaTypeHeaderValue to parse Content-Type This allows avoiding string manipulation to get a multipart boundary, and also ensures no non-interesting parameters interefere with matching. I've removed the check for an empty content type, as it is unnecessary. Also added comments. --- src/Core/Helpers/Helpers.cs | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/Core/Helpers/Helpers.cs b/src/Core/Helpers/Helpers.cs index 0a67b8e..496da3e 100644 --- a/src/Core/Helpers/Helpers.cs +++ b/src/Core/Helpers/Helpers.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Net.Http; +using System.Net.Http.Headers; using System.Threading.Tasks; using System.Web; using Microsoft.AspNetCore.Http; @@ -73,19 +74,29 @@ internal static string TrimTrailingSlashes(this string s) return s.Substring(0, s.Length - count); } - internal static HttpContent ToHttpContent(this IFormCollection collection, string contentType) + internal static HttpContent ToHttpContent(this IFormCollection collection, string contentTypeHeader) { - if (string.IsNullOrWhiteSpace(contentType) || contentType.Equals("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)) + // Form content types resource: https://stackoverflow.com/questions/4526273/what-does-enctype-multipart-form-data-mean/28380690 + // There are three possible form content types: + // - text/plain, which should never be used and this does not handle (a request with that will not have IsFormContentType true anyway) + // - application/x-www-form-urlencoded, which doesn't handle file uploads and escapes any special characters + // - multipart/form-data, which does handle files and doesn't require any escaping, but is quite bulky for short data (due to using some content headers for each value, and a boundary sequence between them) + + // A single form element can have multiple values. When sending them they are handled as separate items with the same name, not a singe item with multiple values. + // For example, a=1&a=2. + + var contentType = MediaTypeHeaderValue.Parse(contentTypeHeader); + + if (contentType.MediaType.Equals("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)) // specification: https://url.spec.whatwg.org/#concept-urlencoded return new FormUrlEncodedContent(collection.SelectMany(formItemList => formItemList.Value.Select(value => new KeyValuePair(formItemList.Key, value)))); - if (!contentType.StartsWith("multipart/form-data;", StringComparison.OrdinalIgnoreCase)) - throw new Exception($"Unknown form content type \"{contentType[0]}\""); + if (!contentType.MediaType.Equals("multipart/form-data", StringComparison.OrdinalIgnoreCase)) + throw new Exception($"Unknown form content type \"{contentType.MediaType}\""); - const string boundary = "boundary="; - var boundaryIndex = contentType.IndexOf(boundary, StringComparison.OrdinalIgnoreCase); - if (boundaryIndex < 0) - throw new Exception("Could not find multipart boundary"); - var delimiter = contentType.Substring(boundaryIndex + boundary.Length).Trim('"'); + // multipart/form-data specification https://tools.ietf.org/html/rfc7578 + // It has each value separated by a boundary sequence, which is specified in the Content-Type header. + // As a proxy it is probably best to reuse the boundary used in the original request as it is not necessarily random. + var delimiter = contentType.Parameters.Single(p => p.Name.Equals("boundary", StringComparison.OrdinalIgnoreCase)).Value.Trim('"'); var multipart = new MultipartFormDataContent(delimiter); foreach (var formVal in collection)