From 47d54b62002c910c6192d7be7624b56bf8c56fd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Fri, 6 Nov 2026 12:14:26 +0300 Subject: [PATCH 1/2] test: Add CSRF regression coverage and update endpoint classification --- .../Program.cs | 1 + .../Infrastructure/UAuthRequestClient.cs | 1 + .../LoginCsrfExploitProofTests.cs | 112 ++++++++++++++++++ .../Server/RateLimitingPartitionTests.cs | 5 +- 4 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 tests/CodeBeam.UltimateAuth.Tests.Integration/LoginCsrfExploitProofTests.cs diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index b6184b9a..8ce3a825 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.Options; using MudBlazor.Services; using MudExtensions.Services; using Scalar.AspNetCore; diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs index eee577e8..df5fdb3d 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs @@ -135,6 +135,7 @@ public async Task TryAndCommitAsync(string tryEndpoint, if (response.ValueKind != JsonValueKind.Object) throw new UAuthProtocolException("Invalid tryAndCommit response."); + if (response.TryGetProperty("status", out var status) && status.ValueKind == JsonValueKind.Number && status.TryGetInt32(out var code) && code == 429) throw UAuthRateLimit.Exception(response.TryGetProperty("retryAfter", out var retry) && retry.ValueKind == JsonValueKind.String ? retry.GetString() : null); try diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginCsrfExploitProofTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginCsrfExploitProofTests.cs new file mode 100644 index 00000000..dc7374bd --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginCsrfExploitProofTests.cs @@ -0,0 +1,112 @@ +using System.Net; +using System.Net.Http.Json; +using AwesomeAssertions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace CodeBeam.UltimateAuth.Tests.Integration.Security; + +public sealed class LoginCsrfExploitProofTests +{ + [Fact] + public async Task CrossOriginLogin_WithoutCsrfProof_MustNotAuthenticateVictimAsAttacker() + { + await using var factory = new AuthServerFactory(); + + var attacker = await factory.CreateLoginUserAsync(); + var deviceId = $"csrf-victim-{Guid.NewGuid():N}"; + + using var victim = CreateBrowser(factory, deviceId); + + // The victim starts unauthenticated. + await AssertUnauthenticatedAsync(victim); + + // The attacker submits valid credentials without CSRF proof. + using var request = new HttpRequestMessage( + HttpMethod.Post, + "/auth/login") + { + Content = JsonContent.Create(new + { + identifier = attacker.Identifier, + secret = attacker.Secret + }) + }; + + request.Headers.TryAddWithoutValidation( + "Origin", + "https://localhost:6130"); + + using var response = await victim.SendAsync(request); + + var body = await response.Content.ReadAsStringAsync(); + + // Prove that the rejection came from CSRF validation. + response.StatusCode.Should().Be( + HttpStatusCode.BadRequest, + $"unexpected login response: {body}"); + + body.Should().Contain( + "invalid_antiforgery_token"); + + // The rejected login must not issue a session cookie. + var sessionCookie = GetSessionCookie(response); + + sessionCookie.Should().BeNull( + "a rejected CSRF login must not establish a session"); + + // The victim must remain unauthenticated. + await AssertUnauthenticatedAsync(victim); + } + + private static async Task AssertUnauthenticatedAsync( + HttpClient client) + { + using var response = await client.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 10 + }); + + response.StatusCode.Should().Be( + HttpStatusCode.Unauthorized); + } + + private static HttpClient CreateBrowser( + AuthServerFactory factory, + string deviceId) + { + var client = factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + client.DefaultRequestHeaders.TryAddWithoutValidation( + "X-UDID", + deviceId); + + return client; + } + + private static string? GetSessionCookie( + HttpResponseMessage response) + { + if (!response.Headers.TryGetValues( + "Set-Cookie", + out var cookies)) + { + return null; + } + + return cookies + .Select(x => x.Split(';', 2)[0]) + .FirstOrDefault(x => x.StartsWith( + "uas=", + StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RateLimitingPartitionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RateLimitingPartitionTests.cs index fef13244..5a6cea5b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RateLimitingPartitionTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RateLimitingPartitionTests.cs @@ -39,13 +39,14 @@ public async Task RejectionForwardsNativeRetryMetadata() public void EveryEndpointHasTheReviewedClassification() { var all = Enum.GetValues().Select(UAuthEndpointClassification.Get).ToArray(); - Assert.Equal(80, all.Length); + Assert.Equal(81, all.Length); Assert.Equal(20, all.Count(x => x.Group == UAuthRateLimitGroup.Sensitive)); Assert.Equal(56, all.Count(x => x.Group == UAuthRateLimitGroup.Standard)); - Assert.Equal(4, all.Count(x => x.Group == UAuthRateLimitGroup.Frequent)); + Assert.Equal(5, all.Count(x => x.Group == UAuthRateLimitGroup.Frequent)); Assert.Equal(UAuthRateLimitGroup.Sensitive, UAuthEndpointClassification.Get(UAuthEndpoint.CredentialResetBeginAnonymous).Group); Assert.Equal(UAuthRateLimitGroup.Standard, UAuthEndpointClassification.Get(UAuthEndpoint.VerifyUserIdentifierAdmin).Group); Assert.Equal(UAuthRateLimitGroup.Frequent, UAuthEndpointClassification.Get(UAuthEndpoint.Logout).Group); + Assert.Equal(UAuthRateLimitGroup.Frequent, UAuthEndpointClassification.Get(UAuthEndpoint.AntiforgeryToken).Group); } [Fact] From 6a38b269f93465371380f89d962debc8bdd300d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Fri, 6 Nov 2026 14:02:23 +0300 Subject: [PATCH 2/2] Fix TrustedOrigin in Request Client and Client Bootstrapper --- .../Infrastructure/UAuthRequestClient.cs | 13 +++++++------ .../Infrastructure/UAuthClientBootstrapper.cs | 8 ++------ 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs index df5fdb3d..f1802b21 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs @@ -146,16 +146,17 @@ public async Task TryAndCommitAsync(string tryEndpoint, catch (JsonException ex) { throw new UAuthProtocolException("Invalid tryAndCommit response.", ex); } } + private string? ResolveTrustedOrigin() { var basePath = _options.Endpoints.BasePath; - if (!Uri.TryCreate(basePath, UriKind.Absolute, out var uri)) - return null; - - if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) - return null; + if (Uri.TryCreate(basePath, UriKind.Absolute, out var hubUri) && + (hubUri.Scheme == Uri.UriSchemeHttp || hubUri.Scheme == Uri.UriSchemeHttps)) + { + return hubUri.GetLeftPart(UriPartial.Authority); + } - return uri.GetLeftPart(UriPartial.Authority); + return null; } } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthClientBootstrapper.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthClientBootstrapper.cs index 39203317..6cee1b18 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthClientBootstrapper.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthClientBootstrapper.cs @@ -45,13 +45,9 @@ public async Task EnsureStartedAsync(CancellationToken ct = default) string? trustedOrigin = null; - if (Uri.TryCreate(basePath, UriKind.Absolute, out var hubUri)) + if (Uri.TryCreate(basePath, UriKind.Absolute, out var hubUri) && + (hubUri.Scheme == Uri.UriSchemeHttp || hubUri.Scheme == Uri.UriSchemeHttps)) { - if (hubUri.Scheme != Uri.UriSchemeHttp && hubUri.Scheme != Uri.UriSchemeHttps) - { - throw new InvalidOperationException("UAuthHub URL must use HTTP or HTTPS."); - } - trustedOrigin = hubUri.GetLeftPart(UriPartial.Authority); }