Skip to content

Commit 7fdba58

Browse files
authored
test: Add CSRF regression coverage and update endpoint classification (#144)
* test: Add CSRF regression coverage and update endpoint classification * Fix TrustedOrigin in Request Client and Client Bootstrapper
1 parent 491f9b1 commit 7fdba58

5 files changed

Lines changed: 126 additions & 14 deletions

File tree

‎samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using Microsoft.AspNetCore.Components;
1515
using Microsoft.AspNetCore.Components.Infrastructure;
1616
using Microsoft.AspNetCore.HttpOverrides;
17+
using Microsoft.Extensions.Options;
1718
using MudBlazor.Services;
1819
using MudExtensions.Services;
1920
using Scalar.AspNetCore;

‎src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ public async Task<TTryResult> TryAndCommitAsync<TTryResult>(string tryEndpoint,
135135

136136
if (response.ValueKind != JsonValueKind.Object)
137137
throw new UAuthProtocolException("Invalid tryAndCommit response.");
138+
138139
if (response.TryGetProperty("status", out var status) && status.ValueKind == JsonValueKind.Number && status.TryGetInt32(out var code) && code == 429)
139140
throw UAuthRateLimit.Exception(response.TryGetProperty("retryAfter", out var retry) && retry.ValueKind == JsonValueKind.String ? retry.GetString() : null);
140141
try
@@ -145,16 +146,17 @@ public async Task<TTryResult> TryAndCommitAsync<TTryResult>(string tryEndpoint,
145146
catch (JsonException ex) { throw new UAuthProtocolException("Invalid tryAndCommit response.", ex); }
146147
}
147148

149+
148150
private string? ResolveTrustedOrigin()
149151
{
150152
var basePath = _options.Endpoints.BasePath;
151153

152-
if (!Uri.TryCreate(basePath, UriKind.Absolute, out var uri))
153-
return null;
154-
155-
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
156-
return null;
154+
if (Uri.TryCreate(basePath, UriKind.Absolute, out var hubUri) &&
155+
(hubUri.Scheme == Uri.UriSchemeHttp || hubUri.Scheme == Uri.UriSchemeHttps))
156+
{
157+
return hubUri.GetLeftPart(UriPartial.Authority);
158+
}
157159

158-
return uri.GetLeftPart(UriPartial.Authority);
160+
return null;
159161
}
160162
}

‎src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthClientBootstrapper.cs‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,9 @@ public async Task EnsureStartedAsync(CancellationToken ct = default)
4545

4646
string? trustedOrigin = null;
4747

48-
if (Uri.TryCreate(basePath, UriKind.Absolute, out var hubUri))
48+
if (Uri.TryCreate(basePath, UriKind.Absolute, out var hubUri) &&
49+
(hubUri.Scheme == Uri.UriSchemeHttp || hubUri.Scheme == Uri.UriSchemeHttps))
4950
{
50-
if (hubUri.Scheme != Uri.UriSchemeHttp && hubUri.Scheme != Uri.UriSchemeHttps)
51-
{
52-
throw new InvalidOperationException("UAuthHub URL must use HTTP or HTTPS.");
53-
}
54-
5551
trustedOrigin = hubUri.GetLeftPart(UriPartial.Authority);
5652
}
5753

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
using System.Net;
2+
using System.Net.Http.Json;
3+
using AwesomeAssertions;
4+
using CodeBeam.UltimateAuth.Core.Contracts;
5+
using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure;
6+
using Microsoft.AspNetCore.Mvc.Testing;
7+
8+
namespace CodeBeam.UltimateAuth.Tests.Integration.Security;
9+
10+
public sealed class LoginCsrfExploitProofTests
11+
{
12+
[Fact]
13+
public async Task CrossOriginLogin_WithoutCsrfProof_MustNotAuthenticateVictimAsAttacker()
14+
{
15+
await using var factory = new AuthServerFactory();
16+
17+
var attacker = await factory.CreateLoginUserAsync();
18+
var deviceId = $"csrf-victim-{Guid.NewGuid():N}";
19+
20+
using var victim = CreateBrowser(factory, deviceId);
21+
22+
// The victim starts unauthenticated.
23+
await AssertUnauthenticatedAsync(victim);
24+
25+
// The attacker submits valid credentials without CSRF proof.
26+
using var request = new HttpRequestMessage(
27+
HttpMethod.Post,
28+
"/auth/login")
29+
{
30+
Content = JsonContent.Create(new
31+
{
32+
identifier = attacker.Identifier,
33+
secret = attacker.Secret
34+
})
35+
};
36+
37+
request.Headers.TryAddWithoutValidation(
38+
"Origin",
39+
"https://localhost:6130");
40+
41+
using var response = await victim.SendAsync(request);
42+
43+
var body = await response.Content.ReadAsStringAsync();
44+
45+
// Prove that the rejection came from CSRF validation.
46+
response.StatusCode.Should().Be(
47+
HttpStatusCode.BadRequest,
48+
$"unexpected login response: {body}");
49+
50+
body.Should().Contain(
51+
"invalid_antiforgery_token");
52+
53+
// The rejected login must not issue a session cookie.
54+
var sessionCookie = GetSessionCookie(response);
55+
56+
sessionCookie.Should().BeNull(
57+
"a rejected CSRF login must not establish a session");
58+
59+
// The victim must remain unauthenticated.
60+
await AssertUnauthenticatedAsync(victim);
61+
}
62+
63+
private static async Task AssertUnauthenticatedAsync(
64+
HttpClient client)
65+
{
66+
using var response = await client.PostAsJsonAsync(
67+
"/auth/me/sessions/chains",
68+
new PageRequest
69+
{
70+
PageNumber = 1,
71+
PageSize = 10
72+
});
73+
74+
response.StatusCode.Should().Be(
75+
HttpStatusCode.Unauthorized);
76+
}
77+
78+
private static HttpClient CreateBrowser(
79+
AuthServerFactory factory,
80+
string deviceId)
81+
{
82+
var client = factory.CreateClient(
83+
new WebApplicationFactoryClientOptions
84+
{
85+
AllowAutoRedirect = false,
86+
HandleCookies = false
87+
});
88+
89+
client.DefaultRequestHeaders.TryAddWithoutValidation(
90+
"X-UDID",
91+
deviceId);
92+
93+
return client;
94+
}
95+
96+
private static string? GetSessionCookie(
97+
HttpResponseMessage response)
98+
{
99+
if (!response.Headers.TryGetValues(
100+
"Set-Cookie",
101+
out var cookies))
102+
{
103+
return null;
104+
}
105+
106+
return cookies
107+
.Select(x => x.Split(';', 2)[0])
108+
.FirstOrDefault(x => x.StartsWith(
109+
"uas=",
110+
StringComparison.OrdinalIgnoreCase));
111+
}
112+
}

‎tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RateLimitingPartitionTests.cs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,14 @@ public async Task RejectionForwardsNativeRetryMetadata()
3939
public void EveryEndpointHasTheReviewedClassification()
4040
{
4141
var all = Enum.GetValues<UAuthEndpoint>().Select(UAuthEndpointClassification.Get).ToArray();
42-
Assert.Equal(80, all.Length);
42+
Assert.Equal(81, all.Length);
4343
Assert.Equal(20, all.Count(x => x.Group == UAuthRateLimitGroup.Sensitive));
4444
Assert.Equal(56, all.Count(x => x.Group == UAuthRateLimitGroup.Standard));
45-
Assert.Equal(4, all.Count(x => x.Group == UAuthRateLimitGroup.Frequent));
45+
Assert.Equal(5, all.Count(x => x.Group == UAuthRateLimitGroup.Frequent));
4646
Assert.Equal(UAuthRateLimitGroup.Sensitive, UAuthEndpointClassification.Get(UAuthEndpoint.CredentialResetBeginAnonymous).Group);
4747
Assert.Equal(UAuthRateLimitGroup.Standard, UAuthEndpointClassification.Get(UAuthEndpoint.VerifyUserIdentifierAdmin).Group);
4848
Assert.Equal(UAuthRateLimitGroup.Frequent, UAuthEndpointClassification.Get(UAuthEndpoint.Logout).Group);
49+
Assert.Equal(UAuthRateLimitGroup.Frequent, UAuthEndpointClassification.Get(UAuthEndpoint.AntiforgeryToken).Group);
4950
}
5051

5152
[Fact]

0 commit comments

Comments
 (0)