Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ public async Task<TTryResult> TryAndCommitAsync<TTryResult>(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
Expand All @@ -145,16 +146,17 @@ public async Task<TTryResult> TryAndCommitAsync<TTryResult>(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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,14 @@ public async Task RejectionForwardsNativeRetryMetadata()
public void EveryEndpointHasTheReviewedClassification()
{
var all = Enum.GetValues<UAuthEndpoint>().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]
Expand Down
Loading