review(Security): fix login open-redirect (High) + stale LDAP doc

Code review at HEAD 7286d320. Security-001 (High): guard returnUrl with a local-URL
check before redirect (open-redirect/phishing vector) + regression test. Security-002:
update stale LdapOptions dev-LDAP doc reference.
This commit is contained in:
Joseph Doherty
2026-06-19 10:22:59 -04:00
parent b1946194d6
commit d23e585cdb
4 changed files with 118 additions and 3 deletions
@@ -453,6 +453,40 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
resp.Headers.Location.OriginalString.ShouldContain("ReturnUrl");
}
/// <summary>
/// Security-001 regression: a successful login with an absolute <c>returnUrl</c> must NOT
/// redirect to the external URL. An attacker can craft
/// <c>/login?returnUrl=https://evil.com</c>; the <see cref="LoginCard"/> component echoes
/// the value into the hidden form field verbatim. The <c>/auth/login</c> endpoint must
/// validate the URL is local before redirecting; it must fall back to <c>"/"</c> (the app
/// root) rather than forwarding to an external host.
/// </summary>
[Fact]
public async Task Login_with_absolute_returnUrl_does_not_open_redirect()
{
var client = NewClientNoRedirect();
// POST to /auth/login with valid credentials AND an absolute returnUrl.
var formContent = new FormUrlEncodedContent(new Dictionary<string, string>
{
["username"] = "alice",
["password"] = "valid-password",
["returnUrl"] = "https://evil.com/steal-session",
});
var resp = await client.PostAsync("/auth/login", formContent, Ct);
// The endpoint must redirect (302), but NOT to the external URL.
resp.StatusCode.ShouldBe(HttpStatusCode.Found,
"a successful form-POST login must always redirect");
var location = resp.Headers.Location?.OriginalString ?? string.Empty;
location.Contains("evil.com").ShouldBeFalse(
"a successful login must not redirect to an externally-supplied absolute URL");
// The fallback destination must be the app root — not an error page.
location.ShouldBe("/", "the safe fallback for an invalid returnUrl is the app root");
}
/// <summary>Anonymous XHR GET of a protected route returns 401 (caller signaled non-browser
/// via the <c>X-Requested-With</c> header — the ASP.NET cookie handler's IsAjaxRequest
/// heuristic). The framework still writes a <c>Location</c> header alongside the 401;