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
@@ -1,5 +1,4 @@
using Microsoft.AspNetCore.Builder;
using CodeBeam.UltimateAuth.Authorization;
using CodeBeam.UltimateAuth.Authorization;
using CodeBeam.UltimateAuth.Core;
using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Defaults;
Expand All @@ -11,6 +10,7 @@
using CodeBeam.UltimateAuth.Core.Options;
using CodeBeam.UltimateAuth.Core.Runtime;
using CodeBeam.UltimateAuth.Credentials;
using CodeBeam.UltimateAuth.Credentials.Extensions;
using CodeBeam.UltimateAuth.Policies.Abstractions;
using CodeBeam.UltimateAuth.Policies.Defaults;
using CodeBeam.UltimateAuth.Policies.Registry;
Expand All @@ -30,13 +30,15 @@
using CodeBeam.UltimateAuth.Server.Stores;
using CodeBeam.UltimateAuth.Users;
using CodeBeam.UltimateAuth.Users.Contracts;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using CodeBeam.UltimateAuth.Credentials.Extensions;

namespace CodeBeam.UltimateAuth.Server.Extensions;

Expand Down Expand Up @@ -321,6 +323,23 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
options.FormFieldName = "__RequestVerificationToken";
});

services.AddOptions<AntiforgeryOptions>().PostConfigure<IOptions<UAuthServerOptions>>((antiforgery, serverOptions) =>
{
var server = serverOptions.Value;

var sameSite = server.Antiforgery.CookieSameSite
?? (server.HubDeploymentMode == UAuthHubDeploymentMode.External
? SameSiteMode.None
: SameSiteMode.Strict);

antiforgery.Cookie.SameSite = sameSite;

if (sameSite == SameSiteMode.None)
{
antiforgery.Cookie.SecurePolicy = CookieSecurePolicy.Always;
}
});

services.AddScoped<UAuthCsrfPolicyEvaluator>();
services.AddScoped<UAuthCsrfValidator>();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace CodeBeam.UltimateAuth.Server.Options;
using Microsoft.AspNetCore.Http;

namespace CodeBeam.UltimateAuth.Server.Options;

/// <summary>
/// Configures CSRF protection for UltimateAuth endpoints.
Expand All @@ -13,8 +15,15 @@ public sealed class UAuthAntiforgeryOptions
/// </summary>
public string TokenEndpoint { get; set; } = "/antiforgery/token";

/// <summary>
/// Overrides the antiforgery cookie SameSite policy.
/// When null, External Hub deployments use None, other deployments retain ASP.NET Core's Strict default.
/// </summary>
public SameSiteMode? CookieSameSite { get; set; }

public UAuthAntiforgeryOptions Clone() => new()
{
TokenEndpoint = TokenEndpoint
TokenEndpoint = TokenEndpoint,
CookieSameSite = CookieSameSite
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@

using AwesomeAssertions;
using CodeBeam.UltimateAuth.Core;
using CodeBeam.UltimateAuth.Server.Options;
using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace CodeBeam.UltimateAuth.Tests.Integration.Security;

public sealed class AntiforgeryCookiePolicyTests
{
[Theory]
[InlineData(UAuthHubDeploymentMode.Embedded, SameSiteMode.Strict)]
[InlineData(UAuthHubDeploymentMode.Integrated, SameSiteMode.Strict)]
[InlineData(UAuthHubDeploymentMode.External, SameSiteMode.None)]
public async Task AntiforgeryCookie_UsesDeploymentPolicy(
UAuthHubDeploymentMode deployment,
SameSiteMode expected)
{
await using var factory = AuthServerFactory.Create(
options => options.HubDeploymentMode = deployment);

var antiforgery = factory.Services
.GetRequiredService<IOptions<AntiforgeryOptions>>()
.Value;

antiforgery.Cookie.SameSite.Should().Be(expected);

if (expected == SameSiteMode.None)
{
antiforgery.Cookie.SecurePolicy
.Should().Be(CookieSecurePolicy.Always);
}
}

[Fact]
public async Task ExternalHub_AntiforgeryTokenCookie_IsSecureAndCrossSite()
{
await using var factory = AuthServerFactory.Create(
options => options.HubDeploymentMode =
UAuthHubDeploymentMode.External);

using var client = factory.CreateClient(
new WebApplicationFactoryClientOptions
{
BaseAddress = new Uri("https://localhost"),
AllowAutoRedirect = false
});

using var response = await client.GetAsync(
"/auth/antiforgery/token",
TestContext.Current.CancellationToken);

response.EnsureSuccessStatusCode();

response.Headers.TryGetValues("Set-Cookie", out var cookies)
.Should().BeTrue();

var cookie = cookies!.Single(x =>
x.StartsWith(
".AspNetCore.Antiforgery.",
StringComparison.OrdinalIgnoreCase));

cookie.Contains(
"samesite=none",
StringComparison.OrdinalIgnoreCase).Should().BeTrue();

cookie.Split(';', StringSplitOptions.TrimEntries)
.Any(x => x.Equals(
"secure",
StringComparison.OrdinalIgnoreCase))
.Should().BeTrue();
}

[Fact]
public async Task ExplicitAntiforgeryCookiePolicy_OverridesDeploymentDefault()
{
await using var factory = AuthServerFactory.Create(options =>
{
options.HubDeploymentMode = UAuthHubDeploymentMode.External;
options.Antiforgery.CookieSameSite = SameSiteMode.Lax;
});

var antiforgery = factory.Services
.GetRequiredService<IOptions<AntiforgeryOptions>>()
.Value;

antiforgery.Cookie.SameSite.Should().Be(SameSiteMode.Lax);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,25 +33,29 @@ public async Task Login_WithoutCsrfToken_MustNotCreateSession()
public async Task Login_WithForgedCsrfToken_MustNotCreateSession()
{
await using var factory = new AuthServerFactory();
var attacker = await factory.CreateLoginUserAsync(ct: TestContext.Current.CancellationToken);
var ct = TestContext.Current.CancellationToken;

var user = await factory.CreateLoginUserAsync(ct: ct);

using var victimBrowser = CreateBrowser(factory);

using var request = new HttpRequestMessage(
HttpMethod.Post, "/auth/login")
// Obtain a real antiforgery cookie and request token.
var tokens = await GetTokens(victimBrowser);

using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/login")
{
Content = JsonContent.Create(new
{
identifier = attacker.Identifier,
secret = attacker.Secret
identifier = user.Identifier,
secret = user.Secret
})
};

request.Headers.TryAddWithoutValidation(
"RequestVerificationToken",
"attacker-forged-token");
// Keep the legitimate cookie, but forge the request token.
request.Headers.TryAddWithoutValidation(tokens.HeaderName, "attacker-forged-token");
request.Headers.TryAddWithoutValidation("Cookie", tokens.Cookie);

using var response = await victimBrowser.SendAsync(request, TestContext.Current.CancellationToken);
using var response = await victimBrowser.SendAsync(request, ct);

await AssertCsrfRejected(response);
AssertNoSessionCookie(response);
Expand Down Expand Up @@ -167,28 +171,26 @@ public async Task RevokeOthers_CsrfAttack_MustNotRevokeSessions()
// The attack must not revoke the other session.
await AssertAuthenticated(otherBrowser);

// ATTACK 2: Forged antiforgery token.
using (var forgedRequest = new HttpRequestMessage(
HttpMethod.Post,
"/auth/me/sessions/revoke-others"))
// ATTACK 2: Valid antiforgery cookie with a forged request token.
var forgedTokens = await GetTokens(currentBrowser);

using (var forgedRequest = new HttpRequestMessage(HttpMethod.Post, "/auth/me/sessions/revoke-others"))
{
forgedRequest.Headers.TryAddWithoutValidation(
"RequestVerificationToken",
"forged-token");
forgedRequest.Headers.TryAddWithoutValidation(forgedTokens.HeaderName, "attacker-forged-token");

using var forged = await currentBrowser.SendAsync(
forgedRequest, TestContext.Current.CancellationToken);
// Preserve the authenticated session cookie and
// include the legitimate antiforgery cookie.
forgedRequest.Headers.TryAddWithoutValidation("Cookie", $"{currentCookie}; {forgedTokens.Cookie}");

using var forged = await currentBrowser.SendAsync(forgedRequest, TestContext.Current.CancellationToken);
await AssertCsrfRejected(forged);
}

// The second attack must not revoke the other session.
// A rejected CSRF request must not revoke the other session.
await AssertAuthenticated(otherBrowser);

// CONTROL: Valid CSRF token must allow the operation.
using (var valid = await CsrfTestClient.PostAsync(
currentBrowser,
"/auth/me/sessions/revoke-others"))
using (var valid = await CsrfTestClient.PostAsync(currentBrowser, "/auth/me/sessions/revoke-others"))
{
valid.IsSuccessStatusCode.Should().BeTrue(
$"valid revoke failed: " +
Expand All @@ -199,15 +201,12 @@ public async Task RevokeOthers_CsrfAttack_MustNotRevokeSessions()
await AssertAuthenticated(currentBrowser);

// The other session must now be revoked.
using var revokedCheck = await CsrfTestClient.PostJsonAsync(
otherBrowser,
"/auth/me/identifiers/get",
new { });
using var revokedCheck = await CsrfTestClient.PostJsonAsync(otherBrowser, "/auth/me/identifiers/get", new { });

revokedCheck.StatusCode.Should().Be(
HttpStatusCode.Unauthorized);
revokedCheck.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}


private static async Task<string> LoginAndGetSessionCookie(
HttpClient browser,
IntegrationTestUser user)
Expand Down
Loading