Skip to content

Commit 02bdf70

Browse files
Fix form actions for apps deployed behind reverse proxy (#51403)
# 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
1 parent 949aa42 commit 02bdf70

File tree

3 files changed

+97
-77
lines changed

3 files changed

+97
-77
lines changed

src/Components/Web/src/HtmlRendering/StaticHtmlRenderer.HtmlWriting.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,12 +351,29 @@ void EmitFormActionIfNotExplicit(TextWriter output, bool isForm, bool hasExplici
351351
output.Write("action");
352352
output.Write('=');
353353
output.Write('\"');
354-
_htmlEncoder.Encode(output, _navigationManager.Uri);
354+
_htmlEncoder.Encode(output, GetRootRelativeUrlForFormAction(_navigationManager));
355355
output.Write('\"');
356356
}
357357
}
358358
}
359359

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

src/Components/Web/test/HtmlRendering/HtmlRendererTest.cs

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Globalization;
55
using System.Text;
6+
using System.Web;
67
using Microsoft.AspNetCore.Components.Forms;
78
using Microsoft.AspNetCore.Components.Forms.Mapping;
89
using Microsoft.AspNetCore.Components.Rendering;
@@ -1106,15 +1107,25 @@ await htmlRenderer.Dispatcher.InvokeAsync(async () =>
11061107
});
11071108
}
11081109

1109-
[Fact]
1110-
public async Task RenderComponentAsync_AddsActionAttributeWithCurrentUrlToFormWithoutAttributes_WhenNoActionSpecified()
1110+
[Theory]
1111+
[InlineData("https://example.com/", "https://example.com", "/")]
1112+
[InlineData("https://example.com/", "https://example.com/", "/")]
1113+
[InlineData("https://example.com/", "https://example.com/page", "/page")]
1114+
[InlineData("https://example.com/", "https://example.com/a/b/c", "/a/b/c")]
1115+
[InlineData("https://example.com/", "https://example.com/a/b/c?q=1&p=hello%20there", "/a/b/c?q=1&p=hello%20there")]
1116+
[InlineData("https://example.com/subdir/", "https://example.com/subdir", "/subdir")]
1117+
[InlineData("https://example.com/subdir/", "https://example.com/subdir/", "/subdir/")]
1118+
[InlineData("https://example.com/a/b/", "https://example.com/a/b/c?q=1&p=2", "/a/b/c?q=1&p=2")]
1119+
[InlineData("http://user:[email protected]:1234/a/b/", "http://user:[email protected]:1234/a/b/c&q=1&p=2", "/a/b/c&q=1&p=2")]
1120+
public async Task RenderComponentAsync_AddsActionAttributeWithCurrentUrlToFormWithoutAttributes_WhenNoActionSpecified(
1121+
string baseUrl, string currentUrl, string expectedAction)
11111122
{
11121123
// Arrange
11131124
var serviceProvider = GetServiceProvider(collection => collection.AddSingleton(new RenderFragment(rtb =>
11141125
{
11151126
rtb.OpenElement(0, "form");
11161127
rtb.CloseElement();
1117-
})).AddScoped<NavigationManager, TestNavigationManager>());
1128+
})).AddScoped<NavigationManager>(_ => new TestNavigationManager(baseUrl, currentUrl)));
11181129

11191130
var htmlRenderer = GetHtmlRenderer(serviceProvider);
11201131
await htmlRenderer.Dispatcher.InvokeAsync(async () =>
@@ -1123,7 +1134,7 @@ await htmlRenderer.Dispatcher.InvokeAsync(async () =>
11231134
var result = await htmlRenderer.RenderComponentAsync<TestComponent>();
11241135

11251136
// Assert
1126-
Assert.Equal("<form action=\"https://www.example.com/page\"></form>", result.ToHtmlString());
1137+
Assert.Equal($"<form action=\"{HttpUtility.HtmlAttributeEncode(expectedAction)}\"></form>", result.ToHtmlString());
11271138
});
11281139
}
11291140

@@ -1145,7 +1156,7 @@ await htmlRenderer.Dispatcher.InvokeAsync(async () =>
11451156
var result = await htmlRenderer.RenderComponentAsync<TestComponent>();
11461157

11471158
// Assert
1148-
Assert.Equal("<form method=\"post\" action=\"https://www.example.com/page\"></form>", result.ToHtmlString());
1159+
Assert.Equal("<form method=\"post\" action=\"/page\"></form>", result.ToHtmlString());
11491160
});
11501161
}
11511162

@@ -1382,7 +1393,21 @@ public void Map(FormValueMappingContext context)
13821393

13831394
private class TestNavigationManager : NavigationManager
13841395
{
1385-
protected override void EnsureInitialized() => Initialize("https://www.example.com/", "https://www.example.com/page");
1396+
private string _baseUrl;
1397+
private string _currentUrl;
1398+
1399+
public TestNavigationManager()
1400+
: this("https://www.example.com/", "https://www.example.com/page")
1401+
{
1402+
}
1403+
1404+
public TestNavigationManager(string baseUrl, string currentUrl)
1405+
{
1406+
_baseUrl = baseUrl;
1407+
_currentUrl = currentUrl;
1408+
}
1409+
1410+
protected override void EnsureInitialized() => Initialize(_baseUrl, _currentUrl);
13861411
}
13871412

13881413
private IServiceProvider GetServiceProvider(Action<IServiceCollection> configure = null)

0 commit comments

Comments
 (0)