513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
130 lines
6.5 KiB
C#
130 lines
6.5 KiB
C#
using NSubstitute;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
using Microsoft.AspNetCore.Antiforgery;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
|
|
|
|
/// <summary>
|
|
/// Regression tests for the logout CSRF fix. <c>POST /auth/logout</c> called
|
|
/// <c>.DisableAntiforgery()</c> and a plain <c>GET /logout</c> route also
|
|
/// signed the user out — either could be triggered cross-site to forcibly log
|
|
/// a user out. Logout is a state-changing authenticated action and must be
|
|
/// CSRF-protected: the POST keeps antiforgery enabled and the state-changing
|
|
/// GET route is removed.
|
|
/// </summary>
|
|
public class AuthEndpointsCsrfTests
|
|
{
|
|
private static IReadOnlyList<RouteEndpoint> BuildEndpoints()
|
|
{
|
|
var builder = WebApplication.CreateBuilder();
|
|
builder.Services.AddRouting();
|
|
builder.Services.AddAntiforgery();
|
|
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Security.SessionActivityTracker>();
|
|
builder.Services.AddSingleton(TimeProvider.System);
|
|
// Dispose the host: an undisposed WebApplication leaks its config
|
|
// PhysicalFileProvider (appsettings reload-watch FileSystemWatcher — a
|
|
// process-wide macOS run-loop thread) and a ConsoleLoggerProcessor
|
|
// thread, which keep the test host process alive after the run.
|
|
using var app = builder.Build();
|
|
app.MapAuthEndpoints();
|
|
|
|
return ((IEndpointRouteBuilder)app).DataSources
|
|
.SelectMany(ds => ds.Endpoints)
|
|
.OfType<RouteEndpoint>()
|
|
.ToList();
|
|
}
|
|
|
|
private static RouteEndpoint? Find(IReadOnlyList<RouteEndpoint> endpoints, string pattern, string method)
|
|
=> endpoints.FirstOrDefault(e =>
|
|
e.RoutePattern.RawText == pattern &&
|
|
(e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains(method) ?? false));
|
|
|
|
/// <summary>Verifies that the logout POST route does not disable antiforgery validation.</summary>
|
|
[Fact]
|
|
public void PostAuthLogout_DoesNotDisableAntiforgery()
|
|
{
|
|
var endpoints = BuildEndpoints();
|
|
var logout = Find(endpoints, "/auth/logout", "POST");
|
|
|
|
Assert.NotNull(logout);
|
|
// DisableAntiforgery() leaves an IAntiforgeryMetadata with
|
|
// RequiresValidation == false. A CSRF-protected POST has either no such
|
|
// metadata, or metadata that still requires validation.
|
|
var antiforgery = logout!.Metadata.GetMetadata<IAntiforgeryMetadata>();
|
|
Assert.True(antiforgery is null || antiforgery.RequiresValidation,
|
|
"POST /auth/logout must keep antiforgery validation enabled.");
|
|
}
|
|
|
|
/// <summary>Verifies that the state-changing GET logout route has been removed.</summary>
|
|
[Fact]
|
|
public void GetLogout_StateChangingRoute_IsRemoved()
|
|
{
|
|
var endpoints = BuildEndpoints();
|
|
var getLogout = Find(endpoints, "/logout", "GET");
|
|
|
|
Assert.Null(getLogout);
|
|
}
|
|
|
|
/// <summary>Verifies that a login POST with a valid antiforgery token authenticates the user and sets the session cookie.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task PostAuthLogin_ValidTokenAuthenticatesAndSetsCookie()
|
|
{
|
|
var endpoint = Find(BuildEndpoints(), "/auth/login", "POST")!;
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddAntiforgery();
|
|
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
|
|
services.AddSingleton(new LoginThrottle(TimeProvider.System, Options.Create(new SecurityOptions())));
|
|
var ldap = Substitute.For<ILdapAuthService>();
|
|
ldap.AuthenticateAsync("alice", "password", Arg.Any<CancellationToken>())
|
|
.Returns(LdapAuthResult.Success("alice", "Alice", []));
|
|
services.AddSingleton(ldap);
|
|
var mapper = Substitute.For<IGroupRoleMapper<string>>();
|
|
mapper.MapAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
|
|
.Returns(new GroupRoleMapping<string>([Roles.Viewer], Scope: null));
|
|
services.AddSingleton(mapper);
|
|
using var provider = services.BuildServiceProvider();
|
|
var tokenContext = new DefaultHttpContext { RequestServices = provider };
|
|
var tokens = provider.GetRequiredService<IAntiforgery>().GetAndStoreTokens(tokenContext);
|
|
var cookie = tokenContext.Response.Headers.SetCookie.Single()!.Split(';')[0];
|
|
var context = new DefaultHttpContext { RequestServices = provider };
|
|
context.Request.Headers.Cookie = cookie;
|
|
context.Request.Method = "POST";
|
|
context.Request.ContentType = "application/x-www-form-urlencoded";
|
|
var form = $"username=alice&password=password&{tokens.FormFieldName}={Uri.EscapeDataString(tokens.RequestToken!)}";
|
|
context.Request.Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(form));
|
|
await endpoint.RequestDelegate!(context);
|
|
Assert.Equal(StatusCodes.Status302Found, context.Response.StatusCode);
|
|
Assert.Equal("/", context.Response.Headers.Location.ToString());
|
|
Assert.Contains(context.Response.Headers.SetCookie, value => value!.StartsWith(".AspNetCore.Cookies="));
|
|
}
|
|
|
|
/// <summary>Verifies that a login POST with a missing antiforgery token is rejected before any credential lookup occurs.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task PostAuthLogin_MissingTokenRejectsBeforeCredentialLookup()
|
|
{
|
|
var endpoint = Find(BuildEndpoints(), "/auth/login", "POST")!;
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddAntiforgery();
|
|
using var provider = services.BuildServiceProvider();
|
|
var context = new DefaultHttpContext { RequestServices = provider };
|
|
context.Request.Method = "POST";
|
|
context.Request.ContentType = "application/x-www-form-urlencoded";
|
|
context.Request.Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("username=test&password=password"));
|
|
await endpoint.RequestDelegate!(context);
|
|
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
|
|
}
|
|
}
|