Skip to content

Commit

Permalink
Fix form actions for apps deployed behind reverse proxy (#51403)
Browse files Browse the repository at this point in the history
# Fix form actions for apps deployed behind reverse proxy

Makes forms work for apps deployed behind a reverse proxy.

## Description

Apps deployed behind a reverse proxy (e.g., container apps) should not try to emit absolute URLs by default because the scheme/hostname/port may differ from what is reachable from the outside world. The fix is to emit root-relative URLs.

Fixes #51380

## Customer Impact

Without this fix, apps deployed behind a reverse proxy (e.g., in ACA) would not support form posts.

## Regression?

- [ ] Yes
- [x] No

No because this only affects SSR forms, which is a new feature in .NET 8.

## Risk

- [ ] High
- [ ] Medium
- [x] Low

Low because this is only a change to how we generate the URL for a form's `action` attribute. Previously we used an absolute URL, but now we use a root-relative one. There is no other runtime change. Everything else in this PR is extra tests and updating existing tests.

## Verification

- [x] Manual (required)
- [x] Automated

## Packaging changes reviewed?

- [ ] Yes
- [ ] No
- [x] N/A
  • Loading branch information
SteveSandersonMS authored Oct 16, 2023
1 parent 949aa42 commit 02bdf70
Show file tree
Hide file tree
Showing 3 changed files with 97 additions and 77 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -351,12 +351,29 @@ void EmitFormActionIfNotExplicit(TextWriter output, bool isForm, bool hasExplici
output.Write("action");
output.Write('=');
output.Write('\"');
_htmlEncoder.Encode(output, _navigationManager.Uri);
_htmlEncoder.Encode(output, GetRootRelativeUrlForFormAction(_navigationManager));
output.Write('\"');
}
}
}

private static string GetRootRelativeUrlForFormAction(NavigationManager navigationManager)
{
// We want a root-relative URL because:
// - if we used a base-relative one, then if currentUrl==baseHref, that would result
// in an empty string, but forms have special handling for action="" (it means "submit
// to the current URL, but that would be wrong if there's an uncommitted navigation in
// flight, e.g., after the user clicking 'back' - it would go to whatever's now in the
// address bar, ignoring where the form was rendered)
// - if we used an absolute URL, then it creates a significant extra pit of failure for
// apps hosted behind a reverse proxy (e.g., container apps), because the server's view
// of the absolute URL isn't usable outside the container
// - of course, sites hosted behind URL rewriting that modifies the path will still be
// wrong, but developers won't do that often as it makes things like <a href> really
// difficult to get right. In that case, developers must emit an action attribute manually.
return new Uri(navigationManager.Uri, UriKind.Absolute).PathAndQuery;
}

private int RenderChildren(int componentId, TextWriter output, ArrayRange<RenderTreeFrame> frames, int position, int maxElements)
{
if (maxElements == 0)
Expand Down
37 changes: 31 additions & 6 deletions src/Components/Web/test/HtmlRendering/HtmlRendererTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Globalization;
using System.Text;
using System.Web;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.AspNetCore.Components.Forms.Mapping;
using Microsoft.AspNetCore.Components.Rendering;
Expand Down Expand Up @@ -1106,15 +1107,25 @@ await htmlRenderer.Dispatcher.InvokeAsync(async () =>
});
}

[Fact]
public async Task RenderComponentAsync_AddsActionAttributeWithCurrentUrlToFormWithoutAttributes_WhenNoActionSpecified()
[Theory]
[InlineData("https://example.com/", "https://example.com", "/")]
[InlineData("https://example.com/", "https://example.com/", "/")]
[InlineData("https://example.com/", "https://example.com/page", "/page")]
[InlineData("https://example.com/", "https://example.com/a/b/c", "/a/b/c")]
[InlineData("https://example.com/", "https://example.com/a/b/c?q=1&p=hello%20there", "/a/b/c?q=1&p=hello%20there")]
[InlineData("https://example.com/subdir/", "https://example.com/subdir", "/subdir")]
[InlineData("https://example.com/subdir/", "https://example.com/subdir/", "/subdir/")]
[InlineData("https://example.com/a/b/", "https://example.com/a/b/c?q=1&p=2", "/a/b/c?q=1&p=2")]
[InlineData("http://user:pass@xyz.example.com:1234/a/b/", "http://user:pass@xyz.example.com:1234/a/b/c&q=1&p=2", "/a/b/c&q=1&p=2")]
public async Task RenderComponentAsync_AddsActionAttributeWithCurrentUrlToFormWithoutAttributes_WhenNoActionSpecified(
string baseUrl, string currentUrl, string expectedAction)
{
// Arrange
var serviceProvider = GetServiceProvider(collection => collection.AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "form");
rtb.CloseElement();
})).AddScoped<NavigationManager, TestNavigationManager>());
})).AddScoped<NavigationManager>(_ => new TestNavigationManager(baseUrl, currentUrl)));

var htmlRenderer = GetHtmlRenderer(serviceProvider);
await htmlRenderer.Dispatcher.InvokeAsync(async () =>
Expand All @@ -1123,7 +1134,7 @@ await htmlRenderer.Dispatcher.InvokeAsync(async () =>
var result = await htmlRenderer.RenderComponentAsync<TestComponent>();
// Assert
Assert.Equal("<form action=\"https://www.example.com/page\"></form>", result.ToHtmlString());
Assert.Equal($"<form action=\"{HttpUtility.HtmlAttributeEncode(expectedAction)}\"></form>", result.ToHtmlString());
});
}

Expand All @@ -1145,7 +1156,7 @@ await htmlRenderer.Dispatcher.InvokeAsync(async () =>
var result = await htmlRenderer.RenderComponentAsync<TestComponent>();
// Assert
Assert.Equal("<form method=\"post\" action=\"https://www.example.com/page\"></form>", result.ToHtmlString());
Assert.Equal("<form method=\"post\" action=\"/page\"></form>", result.ToHtmlString());
});
}

Expand Down Expand Up @@ -1382,7 +1393,21 @@ public void Map(FormValueMappingContext context)

private class TestNavigationManager : NavigationManager
{
protected override void EnsureInitialized() => Initialize("https://www.example.com/", "https://www.example.com/page");
private string _baseUrl;
private string _currentUrl;

public TestNavigationManager()
: this("https://www.example.com/", "https://www.example.com/page")
{
}

public TestNavigationManager(string baseUrl, string currentUrl)
{
_baseUrl = baseUrl;
_currentUrl = currentUrl;
}

protected override void EnsureInitialized() => Initialize(_baseUrl, _currentUrl);
}

private IServiceProvider GetServiceProvider(Action<IServiceCollection> configure = null)
Expand Down
Loading

0 comments on commit 02bdf70

Please sign in to comment.