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

Proxy forms without relying on the request body #61

Merged
merged 6 commits into from
Aug 5, 2020
Merged
Show file tree
Hide file tree
Changes from all 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
15 changes: 12 additions & 3 deletions src/Core/Extensions/Http.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,19 +74,28 @@ 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) &&
!HttpMethods.IsHead(requestMethod) &&
!HttpMethods.IsDelete(requestMethod) &&
!HttpMethods.IsTrace(requestMethod))
{
requestMessage.Content = new StreamContent(request.Body);
if (request.HasFormContentType)
{
usesStreamContent = false;
requestMessage.Content = request.Form.ToHttpContent(request.ContentType);
}
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 (!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());
}
Expand All @@ -98,7 +107,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;
}
Expand Down
46 changes: 46 additions & 0 deletions src/Core/Helpers/Helpers.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
using System;
using System.Collections.Generic;
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;
using Microsoft.Extensions.Primitives;

namespace AspNetCore.Proxy
{
Expand Down Expand Up @@ -67,5 +73,45 @@ internal static string TrimTrailingSlashes(this string s)

return s.Substring(0, s.Length - count);
}

internal static HttpContent ToHttpContent(this IFormCollection collection, string contentTypeHeader)
{
Copy link
Owner

Choose a reason for hiding this comment

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

Can you add just a few comments?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done, I hope they're what you're after. I've also made a few other changes, including at least one that was a fix.

Unfortunately that for some reason has created a code coverage issue for the invalid content type path, and said path is impossible to hit due to how this is used...

Copy link
Owner

Choose a reason for hiding this comment

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

That's fine. I can write unit test to wriggle in there.

// 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<string, string>(formItemList.Key, value))));

if (!contentType.MediaType.Equals("multipart/form-data", StringComparison.OrdinalIgnoreCase))
throw new Exception($"Unknown form content type \"{contentType.MediaType}\"");

// 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)
{
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<string>)header.Value);
multipart.Add(content, file.Name, file.FileName);
}
return multipart;
}
}
}
6 changes: 6 additions & 0 deletions src/Test/Http/HttpHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
29 changes: 29 additions & 0 deletions src/Test/Http/HttpIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<JObject>(json["form"]);
Assert.Equal(2, form.Count);
var xyz = Assert.IsAssignableFrom<JArray>(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<JObject>(json["files"]);
Assert.Single(files);
Assert.Equal(fileString, files["testFile"]);
}

[Fact]
public async Task CanProxyControllerCatchAllPostWithFormRequest()
{
Expand Down